From ba1cf61d93c2359f0fc2da26f942fa041e824652 Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Thu, 1 Oct 2026 23:40:37 -0700 Subject: [PATCH 1/6] feat(room): pure helpers for a meeting's attachment list Co-Authored-By: Claude Opus 5.5 (1M context) --- service/lib/meeting-attachments.js | 56 ++++++++++++++++++++++++++++++ test/meeting-attachments.test.js | 39 +++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 service/lib/meeting-attachments.js create mode 100644 test/meeting-attachments.test.js diff --git a/service/lib/meeting-attachments.js b/service/lib/meeting-attachments.js new file mode 100644 index 0000000..4635f09 --- /dev/null +++ b/service/lib/meeting-attachments.js @@ -0,0 +1,56 @@ +// The nid list a meeting (`schedule` node) keeps in metadata.content.attachments. +// +// Pure: no DB, no session. room.js and dmz.js both read through here, so the +// rule for what counts as a valid attachment id lives in one place — and +// because every nid is checked against NID_RE, callers may interpolate them +// into forward_proc argument strings (dmz.js does). +const NID_RE = /^[0-9a-f]{16}$/; +const MAX_ATTACHMENTS = 20; + +function parse(v) { + if (typeof v !== "string") return v; + try { + return JSON.parse(v); + } catch (e) { + return v; + } +} + +function normalizeNids(input) { + let v = parse(input); + if (v == null) return []; + if (!Array.isArray(v)) v = [v]; + const out = []; + for (const n of v) { + if (typeof n !== "string" || !NID_RE.test(n) || out.includes(n)) continue; + out.push(n); + } + return out; +} + +function attachmentsOf(content) { + const c = parse(content); + return normalizeNids(c && typeof c === "object" ? c.attachments : null); +} + +function mergeAttachments(existing, incoming) { + const list = normalizeNids(existing); + const added = []; + const overflow = []; + for (const n of normalizeNids(incoming)) { + if (list.includes(n)) continue; + if (list.length >= MAX_ATTACHMENTS) { + overflow.push(n); + continue; + } + list.push(n); + added.push(n); + } + return { list, added, overflow }; +} + +function isMeetingNode(node) { + return !!(node && (node.filetype === "schedule" || node.category === "schedule")); +} + +module.exports = { NID_RE, MAX_ATTACHMENTS, normalizeNids, attachmentsOf, mergeAttachments, isMeetingNode }; diff --git a/test/meeting-attachments.test.js b/test/meeting-attachments.test.js new file mode 100644 index 0000000..11a90e8 --- /dev/null +++ b/test/meeting-attachments.test.js @@ -0,0 +1,39 @@ +/** + * Cover for service/lib/meeting-attachments — the nid list a meeting node + * keeps in metadata.content.attachments. + * Run: node test/meeting-attachments.test.js + */ +const assert = require("assert"); +const m = require("../service/lib/meeting-attachments"); + +const A = "0123456789abcdef", B = "fedcba9876543210", C = "00000000000000aa"; + +// normalizeNids: shapes, validation, order, dedupe +assert.deepStrictEqual(m.normalizeNids([A, B, A]), [A, B]); +assert.deepStrictEqual(m.normalizeNids(JSON.stringify([B, A])), [B, A]); +assert.deepStrictEqual(m.normalizeNids(A), [A]); +assert.deepStrictEqual(m.normalizeNids(["x'); DROP", A.toUpperCase(), null, 5, A]), [A]); +assert.deepStrictEqual(m.normalizeNids(undefined), []); +assert.deepStrictEqual(m.normalizeNids("not json ["), []); + +// attachmentsOf: object or JSON content, missing field +assert.deepStrictEqual(m.attachmentsOf({ attachments: [A] }), [A]); +assert.deepStrictEqual(m.attachmentsOf(JSON.stringify({ attachments: [B] })), [B]); +assert.deepStrictEqual(m.attachmentsOf({ title: "x" }), []); +assert.deepStrictEqual(m.attachmentsOf(null), []); + +// mergeAttachments: append, skip existing, cap +assert.deepStrictEqual(m.mergeAttachments([A], [A, B]), { list: [A, B], added: [B], overflow: [] }); +const many = Array.from({ length: m.MAX_ATTACHMENTS }, (_, i) => i.toString(16).padStart(16, "0")); +const r = m.mergeAttachments(many, [C]); +assert.strictEqual(r.list.length, m.MAX_ATTACHMENTS); +assert.deepStrictEqual(r.added, []); +assert.deepStrictEqual(r.overflow, [C]); + +// isMeetingNode +assert.strictEqual(m.isMeetingNode({ filetype: "schedule" }), true); +assert.strictEqual(m.isMeetingNode({ category: "schedule" }), true); +assert.strictEqual(m.isMeetingNode({ filetype: "folder" }), false); +assert.strictEqual(m.isMeetingNode(null), false); + +console.log("meeting-attachments: ok"); From cf0b7ab3b82e22e2f4558e896107faf4a32b002d Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Thu, 1 Oct 2026 23:41:47 -0700 Subject: [PATCH 2/6] feat(room): link_files attaches uploaded files to a meeting; update/public_link/remove carry them Co-Authored-By: Claude Opus 5.5 (1M context) --- acl/room.json | 1578 +++++++++++++++++----------------- service/private/room.js | 66 +- test/room-link-files.test.js | 145 ++++ 3 files changed, 1008 insertions(+), 781 deletions(-) create mode 100644 test/room-link-files.test.js diff --git a/acl/room.json b/acl/room.json index 3605e1b..f7a3806 100644 --- a/acl/room.json +++ b/acl/room.json @@ -1,780 +1,800 @@ -{ - "services": { - "book": { - "doc": "Book a new meeting room. Creates a scheduled meeting node (category=schedule, ext=schedule) in the user home directory with attendees, title, message, and date metadata. Title defaults to a locale-generated headline if not provided. Date defaults to current time if not provided.", - "scope": "hub", - "permission": { - "src": "write", - "fast_check": "user_permission" - }, - "params": { - "title": { - "type": "string", - "required": false, - "description": "Meeting title. Auto-generated from locale template if omitted. Truncated to 100 characters maximum." - }, - "date": { - "type": "string", - "required": false, - "description": "Meeting date/time string. Defaults to current moment formatted as locale long date-time if omitted." - }, - "stime": { - "type": "integer", - "required": false, - "description": "Meeting start time as UNIX-epoch seconds. Canonical, range-queryable time (see room.list). Stored in metadata alongside the display date." - }, - "etime": { - "type": "integer", - "required": false, - "description": "Meeting end time as UNIX-epoch seconds. Canonical, range-queryable time (see room.list)." - }, - "recur": { - "type": "object", - "required": false, - "description": "Recurrence rule { freq: 'daily'|'weekly'|'monthly', until?: epoch-seconds }, or null for a one-off. Stored in metadata; occurrences are expanded client-side on the calendar." - }, - "message": { - "type": "string", - "required": false, - "description": "Meeting invitation message body. Auto-generated from locale template if omitted." - } - }, - "returns": { - "type": "object", - "description": "Created MFS node representing the scheduled meeting", - "properties": { - "id": { - "type": "string", - "description": "Node ID of the created meeting schedule node" - }, - "filename": { - "type": "string", - "description": "Meeting title stored as the node filename" - }, - "category": { - "type": "string", - "description": "Always 'schedule' for meeting nodes" - }, - "metadata": { - "type": "object", - "description": "Meeting metadata including content (attendees, title, message, date, room_id) and room_status=booked" - } - } - }, - "errors": [] - }, - - "get_meeting_members": { - "doc": "Get all members invited to a meeting room. Returns the list of DMZ users who have been granted access to a specific meeting node.", - "scope": "hub", - "permission": { - "src": "admin" - }, - "params": { - "nid": { - "type": "string", - "required": true, - "description": "Node ID of the meeting schedule to retrieve members for" - } - }, - "returns": { - "type": "array", - "description": "List of meeting members with their access details", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Member user ID" - }, - "email": { - "type": "string", - "description": "Member email address" - }, - "name": { - "type": "string", - "description": "Member display name" - } - } - } - }, - "errors": [] - }, - - "get_screen": { - "doc": "Initialize a screen sharing session for a room. Generates a unique screen_id, grants permission on it, and sends room_invite_next to all current room attendees assigning the requester as presenter and others as listeners. Only available for non-private hub areas.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": { - "parent_id": { - "type": "string", - "required": true, - "description": "ID of the parent room for which screen sharing is being initiated" - }, - "parent_type": { - "type": "string", - "required": true, - "description": "Type of the parent room (e.g. room type identifier)" - }, - "socket_id": { - "type": "string", - "required": true, - "description": "WebSocket socket ID of the requesting user" - }, - "device_id": { - "type": "string", - "required": true, - "description": "Device ID of the requesting user" - } - }, - "returns": { - "type": "object", - "description": "New screen sharing room data", - "properties": { - "id": { - "type": "string", - "description": "Generated screen room ID" - }, - "type": { - "type": "string", - "description": "Always 'screen' for screen sharing rooms" - }, - "socket_id": { - "type": "string", - "description": "Socket ID of the session" - } - } - }, - "errors": [] - }, - - "get": { - "doc": "Get or create a room for the current user. Retrieves an existing room matching the given device, socket, and room type, or creates a new one if none exists. Mapped to the get_or_create implementation method.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "method": "get_or_create", - "params": { - "device_id": { - "type": "string", - "required": true, - "description": "Device ID of the user" - }, - "socket_id": { - "type": "string", - "required": true, - "description": "WebSocket socket ID of the user" - }, - "room_type": { - "type": "string", - "required": true, - "description": "Type of room to get or create (e.g. meeting, screen)" - }, - "id": { - "type": "string", - "required": false, - "description": "Specific room ID to look up. If omitted, a new room is created." - } - }, - "returns": { - "type": "object", - "description": "Room record retrieved or created", - "properties": { - "id": { - "type": "string", - "description": "Room ID" - }, - "type": { - "type": "string", - "description": "Room type" - }, - "user_id": { - "type": "string", - "description": "Owner user ID" - }, - "socket_id": { - "type": "string", - "description": "Socket ID associated with the room" - }, - "device_id": { - "type": "string", - "description": "Device ID associated with the room" - } - } - }, - "errors": [] - }, - - "hello": { - "doc": "Register a guest user session for public meeting access. Binds a guest name to the current session cookie via cookie_bind_guest. Used as the entry point for unauthenticated users joining a shared meeting link.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "public-api" - }, - "params": { - "name": { - "type": "string", - "required": true, - "description": "Display name for the guest user joining the meeting" - } - }, - "returns": { - "type": "object", - "description": "Empty confirmation object on successful guest session binding" - }, - "errors": [] - }, - - "invite": { - "doc": "Invite a peer to join an active room. Sends a room_invite_next event to the target guest via WebSocket (signaling.message) with linkup type, assigning them the listener role. Notifies the guest socket on their endpoint node.", - "scope": "hub", - "permission": { - "src": "write", - "fast_check": "user_permission" - }, - "params": { - "room_id": { - "type": "string", - "required": true, - "description": "ID of the room to invite the guest into" - }, - "guest": { - "type": "object", - "required": true, - "description": "Guest user object containing uid, socket_id, device_id, and optionally node (endpoint address)" - }, - "room_type": { - "type": "string", - "required": true, - "description": "Type of the room being joined" - } - }, - "returns": { - "type": "array", - "description": "List containing guest connection info and signaling data payload", - "items": { - "type": "object", - "properties": { - "uid": { - "type": "string", - "description": "Guest user ID" - }, - "socket_id": { - "type": "string", - "description": "Guest socket ID" - }, - "type": { - "type": "string", - "description": "Signaling type, always 'linkup'" - }, - "service": { - "type": "string", - "description": "Signaling service identifier, always 'signaling.message'" - } - } - } - }, - "errors": [] - }, - - "join": { - "doc": "Join an active room and notify the hub owner. Retrieves or creates a room entry, attaches hub area and node details, then sends a WebSocket notification to the hub owner's sockets. Returns room data including current attendees list.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": { - "id": { - "type": "string", - "required": true, - "description": "Room node ID to join" - }, - "socket_id": { - "type": "string", - "required": true, - "description": "WebSocket socket ID of the joining user" - }, - "device_id": { - "type": "string", - "required": true, - "description": "Device ID of the joining user" - }, - "room_type": { - "type": "string", - "required": true, - "description": "Type of the room being joined" - }, - "endpointAddress": { - "type": "string", - "required": false, - "description": "Server endpoint address. Falls back to Cache environment value if omitted." - }, - "endpointRoute": { - "type": "string", - "required": false, - "description": "Server endpoint route. Falls back to Cache environment value if omitted." - } - }, - "returns": { - "type": "object", - "description": "Room data with hub context and attendees", - "properties": { - "wicket_id": { - "type": "string", - "description": "Hub owner ID" - }, - "area": { - "type": "string", - "description": "Hub area (private, dmz, etc.)" - }, - "details": { - "type": "object", - "description": "Sanitized MFS node attributes for the room node" - }, - "attendees": { - "type": "array", - "description": "Current list of room attendees" - } - } - }, - "errors": [] - }, - - "leave": { - "doc": "Leave an active room and notify remaining peers. Removes the user from the room via room_leave_next, pushes user online status to 1 (online/idle), and sends a room.leave signaling message to all remaining attendees via WebSocket.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": { - "socket_id": { - "type": "string", - "required": true, - "description": "WebSocket socket ID of the leaving user" - }, - "room_id": { - "type": "string", - "required": true, - "description": "ID of the room being left" - }, - "hub_id": { - "type": "string", - "required": true, - "description": "Hub ID containing the room" - } - }, - "returns": { - "type": "object", - "description": "Unified room data after the user has left" - }, - "errors": [] - }, - - "public_link": { - "doc": "Generate a public shareable link for a meeting room node. Creates a DMZ grant token for the specified node, grants permission to the public_id system user, and returns a shareable URL containing the access token.", - "scope": "hub", - "permission": { - "src": "admin" - }, - "params": { - "nid": { - "type": "string", - "required": true, - "description": "Node ID of the meeting room to generate a public link for" - }, - "password": { - "type": "string", - "required": false, - "description": "Optional password to protect the public link" - }, - "days": { - "type": "integer", - "required": false, - "default": 0, - "description": "Number of days for link expiry. Combined with hours to compute total expiry in hours." - }, - "hours": { - "type": "integer", - "required": false, - "default": 0, - "description": "Number of hours for link expiry. Combined with days to compute total expiry." - }, - "permission": { - "type": "integer", - "required": false, - "description": "Permission level to grant on the node. Defaults to Privilege.write if omitted." - } - }, - "returns": { - "type": "object", - "description": "Public shareable link for the meeting room", - "properties": { - "link": { - "type": "string", - "description": "Full shareable URL with DMZ access token appended as path segment" - } - } - }, - "errors": [] - }, - - "shutdown": { - "doc": "Shut down an active room session. Reserved for admin-level room lifecycle management. Returns an empty object on completion.", - "scope": "hub", - "permission": { - "src": "admin" - }, - "params": { - "room_id": { - "type": "string", - "required": true, - "description": "ID of the room to shut down" - } - }, - "returns": { - "type": "object", - "description": "Empty confirmation object" - }, - "errors": [] - }, - - "check_availability": { - "doc": "Free/busy for a proposed slot (workspace-scoped). Given attendee uids + [stime,etime], returns which invitees already have a meeting in this hub overlapping the slot. Warn-only; the organizer can still book.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": { - "stime": { "type": "integer", "required": true, "description": "Proposed start (epoch seconds)" }, - "etime": { "type": "integer", "required": true, "description": "Proposed end (epoch seconds)" }, - "attendees": { "type": "array", "required": false, "description": "Invitee list [{uid,name}] to check" }, - "nid": { "type": "string", "required": false, "description": "Meeting being edited, excluded from its own check" } - }, - "returns": { - "type": "array", - "description": "Per-invitee availability", - "items": { - "type": "object", - "properties": { - "uid": { "type": "string" }, - "busy": { "type": "boolean" }, - "conflicts": { "type": "array", "description": "Overlapping meetings [{nid,title,stime,etime}]" } - } - } - }, - "errors": [] - }, - - "list": { - "doc": "List the current hub's scheduled meetings whose time window overlaps [stime, etime]. Reads schedule nodes (category=schedule) via room_list_scheduled, filtering on the canonical UNIX-epoch time persisted in node metadata. Both bounds optional; omit to return every scheduled meeting. Feeds the folder-window meeting calendar.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": { - "stime": { - "type": "integer", - "required": false, - "description": "Range start as UNIX-epoch seconds. Omit (with etime) for no time filter." - }, - "etime": { - "type": "integer", - "required": false, - "description": "Range end as UNIX-epoch seconds. Omit (with stime) for no time filter." - } - }, - "returns": { - "type": "array", - "description": "Scheduled meetings in range, ordered by start time", - "items": { - "type": "object", - "properties": { - "id": { "type": "string", "description": "Meeting node id (also room_id)" }, - "filename": { "type": "string", "description": "Meeting title" }, - "stime": { "type": "integer", "description": "Start time (epoch seconds)" }, - "etime": { "type": "integer", "description": "End time (epoch seconds)" }, - "metadata": { "type": "object", "description": "Full node metadata (content: attendees/title/message/date/stime/etime/room_id)" } - } - } - }, - "errors": [] - }, - - "remove": { - "doc": "Remove a meeting room and revoke all permissions. Revokes all permissions on the meeting node using permission_revoke with scope 'meeting' and returns the node ID as confirmation.", - "scope": "hub", - "permission": { - "src": "write", - "fast_check": "user_permission" - }, - "params": { - "nid": { - "type": "string", - "required": true, - "description": "Node ID of the meeting room to remove" - } - }, - "returns": { - "type": "object", - "description": "Confirmation of removal", - "properties": { - "nid": { - "type": "string", - "description": "Node ID of the removed meeting room" - } - } - }, - "errors": [] - }, - - "request_screen_access": { - "doc": "Request access to a peer screen sharing session. Reserved endpoint for future screen access request workflow. Currently not active in the service implementation.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": {}, - "returns": { - "type": "object", - "description": "Reserved" - }, - "errors": [] - }, - - "requestAccess": { - "doc": "Request access to join an active room as a participant. Verifies room permission via room_access, retrieves presenter and current peers, pushes user online status to 2 (in-meeting), and if the requester is the presenter sends a meeting.start signal to all peers.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "public-api" - }, - "params": { - "socket_id": { - "type": "string", - "required": true, - "description": "WebSocket socket ID of the requesting user" - }, - "room_id": { - "type": "string", - "required": true, - "description": "ID of the room to request access to" - }, - "hub_id": { - "type": "string", - "required": true, - "description": "Hub ID containing the room" - } - }, - "returns": { - "type": "object", - "description": "Room access context with peer and presenter information", - "properties": { - "presenter": { - "type": "object", - "description": "Current presenter data from room_get_presenter" - }, - "peers": { - "type": "array", - "description": "List of current room attendees excluding the requesting socket" - }, - "presenter_id": { - "type": "string", - "description": "User ID of the current presenter, or null if none" - }, - "room_id": { - "type": "string", - "description": "Room ID from the access record" - }, - "user": { - "type": "object", - "description": "Requesting user info (avatar_id, deviceId, role, ssid, uid, username)" - }, - "ssid": { - "type": "string", - "description": "Socket ID from the access record" - }, - "status": { - "type": "string", - "description": "Room access status" - }, - "peer": { - "type": "object", - "description": "Alias of user object for peer-to-peer signaling" - } - } - }, - "errors": [ - { - "code": "WEAK_PRIVILEGE OOOP", - "message": "User does not have sufficient permission to access the room", - "condition": "room_access returns no record or permission is falsy" - } - ] - }, - - "unified_room": { - "doc": "Manage unified room state for multi-participant sessions. Supports four operations via the flag parameter: get (retrieve room state), add (join with media settings using defaults), update (join with explicit required media settings), and remove (leave the unified room).", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "public-api" - }, - "params": { - "flag": { - "type": "string", - "required": true, - "description": "Operation to perform: 'get' to retrieve, 'add' to join with optional settings, 'update' to update with required settings, 'remove' to leave" - }, - "id": { - "type": "string", - "required": true, - "description": "Room ID to operate on" - }, - "uid": { - "type": "string", - "required": false, - "description": "User ID. Defaults to empty string if omitted." - }, - "is_mic_enabled": { - "type": "integer", - "required": false, - "description": "Microphone enabled state (0 or 1). Required when flag=update. Defaults to 1 when flag=add." - }, - "is_video_enabled": { - "type": "integer", - "required": false, - "description": "Camera enabled state (0 or 1). Required when flag=update. Defaults to 0 when flag=add." - }, - "is_share_enabled": { - "type": "integer", - "required": false, - "description": "Screen share enabled state (0 or 1). Required when flag=update. Defaults to 0 when flag=add." - }, - "is_write_enabled": { - "type": "integer", - "required": false, - "description": "Write/annotation enabled state (0 or 1). Required when flag=update. Defaults to 0 when flag=add." - }, - "metadata": { - "type": "object", - "required": false, - "description": "Additional room metadata object. Used when flag=add or flag=update. Defaults to empty object." - } - }, - "returns": { - "type": "array", - "description": "List of unified room records after the operation" - }, - "errors": [] - }, - - "update": { - "doc": "Update a scheduled meeting room. Supports partial or full updates controlled by the flag parameter. Flags: 'when' updates date only, 'title' updates title and renames the node, 'agenda' updates message, 'member' updates attendees and sends email invitations, 'all' updates all fields. Commits invitation emails to new attendees when flag includes member changes.", - "scope": "hub", - "permission": { - "src": "write", - "fast_check": "user_permission" - }, - "params": { - "flag": { - "type": "string", - "required": true, - "description": "Update scope: 'when' (date), 'title', 'agenda' (message), 'member' (attendees), or 'all' (all fields)" - }, - "nid": { - "type": "string", - "required": true, - "description": "Node ID of the meeting room to update" - }, - "date": { - "type": "string", - "required": false, - "description": "New meeting date/time string. Applied when flag is 'when' or 'all'." - }, - "stime": { - "type": "integer", - "required": false, - "description": "New meeting start time as UNIX-epoch seconds. Applied when flag is 'when' or 'all'; preserved if omitted." - }, - "etime": { - "type": "integer", - "required": false, - "description": "New meeting end time as UNIX-epoch seconds. Applied when flag is 'when' or 'all'; preserved if omitted." - }, - "title": { - "type": "string", - "required": false, - "description": "New meeting title. Applied when flag is 'title' or 'all'. Truncated to 100 characters." - }, - "message": { - "type": "string", - "required": false, - "description": "New meeting message/agenda. Applied when flag is 'agenda' or 'all'." - }, - "attendees": { - "type": "array", - "required": false, - "description": "Updated attendee list of WORKSPACE MEMBERS (applied when flag is 'member' or 'all'). Each entry is { uid, name } — no email/DMZ invite. Newly-added members get an in-app notification; visibility is workspace-wide." - }, - "recur": { - "type": "object", - "required": false, - "description": "Recurrence rule { freq: 'daily'|'weekly'|'monthly', until?: epoch-seconds } or null to clear. Flag-agnostic: applied whenever sent." - } - }, - "returns": { - "type": "object", - "description": "Updated meeting content fields", - "properties": { - "attendees": { - "type": "array", - "description": "Updated list of attendees" - }, - "title": { - "type": "string", - "description": "Updated meeting title" - }, - "message": { - "type": "string", - "description": "Updated meeting message/agenda" - }, - "date": { - "type": "string", - "description": "Updated meeting date/time" - } - } - }, - "errors": [] - }, - - "users": { - "doc": "List all users currently in a room. Reserved endpoint for room participant listing. Currently not active in the service implementation.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": {}, - "returns": { - "type": "array", - "description": "Reserved" - }, - "errors": [] - } - }, - "modules": { - "public": "service/room", - "private": "service/private/room" - } +{ + "services": { + "book": { + "doc": "Book a new meeting room. Creates a scheduled meeting node (category=schedule, ext=schedule) in the user home directory with attendees, title, message, and date metadata. Title defaults to a locale-generated headline if not provided. Date defaults to current time if not provided.", + "scope": "hub", + "permission": { + "src": "write", + "fast_check": "user_permission" + }, + "params": { + "title": { + "type": "string", + "required": false, + "description": "Meeting title. Auto-generated from locale template if omitted. Truncated to 100 characters maximum." + }, + "date": { + "type": "string", + "required": false, + "description": "Meeting date/time string. Defaults to current moment formatted as locale long date-time if omitted." + }, + "stime": { + "type": "integer", + "required": false, + "description": "Meeting start time as UNIX-epoch seconds. Canonical, range-queryable time (see room.list). Stored in metadata alongside the display date." + }, + "etime": { + "type": "integer", + "required": false, + "description": "Meeting end time as UNIX-epoch seconds. Canonical, range-queryable time (see room.list)." + }, + "recur": { + "type": "object", + "required": false, + "description": "Recurrence rule { freq: 'daily'|'weekly'|'monthly', until?: epoch-seconds }, or null for a one-off. Stored in metadata; occurrences are expanded client-side on the calendar." + }, + "message": { + "type": "string", + "required": false, + "description": "Meeting invitation message body. Auto-generated from locale template if omitted." + } + }, + "returns": { + "type": "object", + "description": "Created MFS node representing the scheduled meeting", + "properties": { + "id": { + "type": "string", + "description": "Node ID of the created meeting schedule node" + }, + "filename": { + "type": "string", + "description": "Meeting title stored as the node filename" + }, + "category": { + "type": "string", + "description": "Always 'schedule' for meeting nodes" + }, + "metadata": { + "type": "object", + "description": "Meeting metadata including content (attendees, title, message, date, room_id) and room_status=booked" + } + } + }, + "errors": [] + }, + + "get_meeting_members": { + "doc": "Get all members invited to a meeting room. Returns the list of DMZ users who have been granted access to a specific meeting node.", + "scope": "hub", + "permission": { + "src": "admin" + }, + "params": { + "nid": { + "type": "string", + "required": true, + "description": "Node ID of the meeting schedule to retrieve members for" + } + }, + "returns": { + "type": "array", + "description": "List of meeting members with their access details", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Member user ID" + }, + "email": { + "type": "string", + "description": "Member email address" + }, + "name": { + "type": "string", + "description": "Member display name" + } + } + } + }, + "errors": [] + }, + + "get_screen": { + "doc": "Initialize a screen sharing session for a room. Generates a unique screen_id, grants permission on it, and sends room_invite_next to all current room attendees assigning the requester as presenter and others as listeners. Only available for non-private hub areas.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": { + "parent_id": { + "type": "string", + "required": true, + "description": "ID of the parent room for which screen sharing is being initiated" + }, + "parent_type": { + "type": "string", + "required": true, + "description": "Type of the parent room (e.g. room type identifier)" + }, + "socket_id": { + "type": "string", + "required": true, + "description": "WebSocket socket ID of the requesting user" + }, + "device_id": { + "type": "string", + "required": true, + "description": "Device ID of the requesting user" + } + }, + "returns": { + "type": "object", + "description": "New screen sharing room data", + "properties": { + "id": { + "type": "string", + "description": "Generated screen room ID" + }, + "type": { + "type": "string", + "description": "Always 'screen' for screen sharing rooms" + }, + "socket_id": { + "type": "string", + "description": "Socket ID of the session" + } + } + }, + "errors": [] + }, + + "get": { + "doc": "Get or create a room for the current user. Retrieves an existing room matching the given device, socket, and room type, or creates a new one if none exists. Mapped to the get_or_create implementation method.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "method": "get_or_create", + "params": { + "device_id": { + "type": "string", + "required": true, + "description": "Device ID of the user" + }, + "socket_id": { + "type": "string", + "required": true, + "description": "WebSocket socket ID of the user" + }, + "room_type": { + "type": "string", + "required": true, + "description": "Type of room to get or create (e.g. meeting, screen)" + }, + "id": { + "type": "string", + "required": false, + "description": "Specific room ID to look up. If omitted, a new room is created." + } + }, + "returns": { + "type": "object", + "description": "Room record retrieved or created", + "properties": { + "id": { + "type": "string", + "description": "Room ID" + }, + "type": { + "type": "string", + "description": "Room type" + }, + "user_id": { + "type": "string", + "description": "Owner user ID" + }, + "socket_id": { + "type": "string", + "description": "Socket ID associated with the room" + }, + "device_id": { + "type": "string", + "description": "Device ID associated with the room" + } + } + }, + "errors": [] + }, + + "hello": { + "doc": "Register a guest user session for public meeting access. Binds a guest name to the current session cookie via cookie_bind_guest. Used as the entry point for unauthenticated users joining a shared meeting link.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "public-api" + }, + "params": { + "name": { + "type": "string", + "required": true, + "description": "Display name for the guest user joining the meeting" + } + }, + "returns": { + "type": "object", + "description": "Empty confirmation object on successful guest session binding" + }, + "errors": [] + }, + + "invite": { + "doc": "Invite a peer to join an active room. Sends a room_invite_next event to the target guest via WebSocket (signaling.message) with linkup type, assigning them the listener role. Notifies the guest socket on their endpoint node.", + "scope": "hub", + "permission": { + "src": "write", + "fast_check": "user_permission" + }, + "params": { + "room_id": { + "type": "string", + "required": true, + "description": "ID of the room to invite the guest into" + }, + "guest": { + "type": "object", + "required": true, + "description": "Guest user object containing uid, socket_id, device_id, and optionally node (endpoint address)" + }, + "room_type": { + "type": "string", + "required": true, + "description": "Type of the room being joined" + } + }, + "returns": { + "type": "array", + "description": "List containing guest connection info and signaling data payload", + "items": { + "type": "object", + "properties": { + "uid": { + "type": "string", + "description": "Guest user ID" + }, + "socket_id": { + "type": "string", + "description": "Guest socket ID" + }, + "type": { + "type": "string", + "description": "Signaling type, always 'linkup'" + }, + "service": { + "type": "string", + "description": "Signaling service identifier, always 'signaling.message'" + } + } + } + }, + "errors": [] + }, + + "join": { + "doc": "Join an active room and notify the hub owner. Retrieves or creates a room entry, attaches hub area and node details, then sends a WebSocket notification to the hub owner's sockets. Returns room data including current attendees list.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": { + "id": { + "type": "string", + "required": true, + "description": "Room node ID to join" + }, + "socket_id": { + "type": "string", + "required": true, + "description": "WebSocket socket ID of the joining user" + }, + "device_id": { + "type": "string", + "required": true, + "description": "Device ID of the joining user" + }, + "room_type": { + "type": "string", + "required": true, + "description": "Type of the room being joined" + }, + "endpointAddress": { + "type": "string", + "required": false, + "description": "Server endpoint address. Falls back to Cache environment value if omitted." + }, + "endpointRoute": { + "type": "string", + "required": false, + "description": "Server endpoint route. Falls back to Cache environment value if omitted." + } + }, + "returns": { + "type": "object", + "description": "Room data with hub context and attendees", + "properties": { + "wicket_id": { + "type": "string", + "description": "Hub owner ID" + }, + "area": { + "type": "string", + "description": "Hub area (private, dmz, etc.)" + }, + "details": { + "type": "object", + "description": "Sanitized MFS node attributes for the room node" + }, + "attendees": { + "type": "array", + "description": "Current list of room attendees" + } + } + }, + "errors": [] + }, + + "leave": { + "doc": "Leave an active room and notify remaining peers. Removes the user from the room via room_leave_next, pushes user online status to 1 (online/idle), and sends a room.leave signaling message to all remaining attendees via WebSocket.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": { + "socket_id": { + "type": "string", + "required": true, + "description": "WebSocket socket ID of the leaving user" + }, + "room_id": { + "type": "string", + "required": true, + "description": "ID of the room being left" + }, + "hub_id": { + "type": "string", + "required": true, + "description": "Hub ID containing the room" + } + }, + "returns": { + "type": "object", + "description": "Unified room data after the user has left" + }, + "errors": [] + }, + + "link_files": { + "doc": "Attach already-uploaded files (media nids in this hub) to a meeting the caller created. Appends to the meeting's attachment list (deduped, max 20) and, when the meeting already has a public link, grants that link download access to the new files. Returns the full list and any nids refused for the cap.", + "scope": "hub", + "permission": { + "src": "write", + "fast_check": "user_permission" + }, + "params": { + "nid": { + "type": "string", + "required": true, + "description": "Meeting (schedule) node id" + }, + "file_nids": { + "type": "array", + "required": true, + "description": "Media node ids to attach" + } + } + }, + "public_link": { + "doc": "Generate a public shareable link for a meeting room node. Creates a DMZ grant token for the specified node, grants permission to the public_id system user, and returns a shareable URL containing the access token.", + "scope": "hub", + "permission": { + "src": "admin" + }, + "params": { + "nid": { + "type": "string", + "required": true, + "description": "Node ID of the meeting room to generate a public link for" + }, + "password": { + "type": "string", + "required": false, + "description": "Optional password to protect the public link" + }, + "days": { + "type": "integer", + "required": false, + "default": 0, + "description": "Number of days for link expiry. Combined with hours to compute total expiry in hours." + }, + "hours": { + "type": "integer", + "required": false, + "default": 0, + "description": "Number of hours for link expiry. Combined with days to compute total expiry." + }, + "permission": { + "type": "integer", + "required": false, + "description": "Permission level to grant on the node. Defaults to Privilege.write if omitted." + } + }, + "returns": { + "type": "object", + "description": "Public shareable link for the meeting room", + "properties": { + "link": { + "type": "string", + "description": "Full shareable URL with DMZ access token appended as path segment" + } + } + }, + "errors": [] + }, + + "shutdown": { + "doc": "Shut down an active room session. Reserved for admin-level room lifecycle management. Returns an empty object on completion.", + "scope": "hub", + "permission": { + "src": "admin" + }, + "params": { + "room_id": { + "type": "string", + "required": true, + "description": "ID of the room to shut down" + } + }, + "returns": { + "type": "object", + "description": "Empty confirmation object" + }, + "errors": [] + }, + + "check_availability": { + "doc": "Free/busy for a proposed slot (workspace-scoped). Given attendee uids + [stime,etime], returns which invitees already have a meeting in this hub overlapping the slot. Warn-only; the organizer can still book.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": { + "stime": { "type": "integer", "required": true, "description": "Proposed start (epoch seconds)" }, + "etime": { "type": "integer", "required": true, "description": "Proposed end (epoch seconds)" }, + "attendees": { "type": "array", "required": false, "description": "Invitee list [{uid,name}] to check" }, + "nid": { "type": "string", "required": false, "description": "Meeting being edited, excluded from its own check" } + }, + "returns": { + "type": "array", + "description": "Per-invitee availability", + "items": { + "type": "object", + "properties": { + "uid": { "type": "string" }, + "busy": { "type": "boolean" }, + "conflicts": { "type": "array", "description": "Overlapping meetings [{nid,title,stime,etime}]" } + } + } + }, + "errors": [] + }, + + "list": { + "doc": "List the current hub's scheduled meetings whose time window overlaps [stime, etime]. Reads schedule nodes (category=schedule) via room_list_scheduled, filtering on the canonical UNIX-epoch time persisted in node metadata. Both bounds optional; omit to return every scheduled meeting. Feeds the folder-window meeting calendar.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": { + "stime": { + "type": "integer", + "required": false, + "description": "Range start as UNIX-epoch seconds. Omit (with etime) for no time filter." + }, + "etime": { + "type": "integer", + "required": false, + "description": "Range end as UNIX-epoch seconds. Omit (with stime) for no time filter." + } + }, + "returns": { + "type": "array", + "description": "Scheduled meetings in range, ordered by start time", + "items": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Meeting node id (also room_id)" }, + "filename": { "type": "string", "description": "Meeting title" }, + "stime": { "type": "integer", "description": "Start time (epoch seconds)" }, + "etime": { "type": "integer", "description": "End time (epoch seconds)" }, + "metadata": { "type": "object", "description": "Full node metadata (content: attendees/title/message/date/stime/etime/room_id)" } + } + } + }, + "errors": [] + }, + + "remove": { + "doc": "Remove a meeting room and revoke all permissions. Revokes all permissions on the meeting node using permission_revoke with scope 'meeting' and returns the node ID as confirmation.", + "scope": "hub", + "permission": { + "src": "write", + "fast_check": "user_permission" + }, + "params": { + "nid": { + "type": "string", + "required": true, + "description": "Node ID of the meeting room to remove" + } + }, + "returns": { + "type": "object", + "description": "Confirmation of removal", + "properties": { + "nid": { + "type": "string", + "description": "Node ID of the removed meeting room" + } + } + }, + "errors": [] + }, + + "request_screen_access": { + "doc": "Request access to a peer screen sharing session. Reserved endpoint for future screen access request workflow. Currently not active in the service implementation.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": {}, + "returns": { + "type": "object", + "description": "Reserved" + }, + "errors": [] + }, + + "requestAccess": { + "doc": "Request access to join an active room as a participant. Verifies room permission via room_access, retrieves presenter and current peers, pushes user online status to 2 (in-meeting), and if the requester is the presenter sends a meeting.start signal to all peers.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "public-api" + }, + "params": { + "socket_id": { + "type": "string", + "required": true, + "description": "WebSocket socket ID of the requesting user" + }, + "room_id": { + "type": "string", + "required": true, + "description": "ID of the room to request access to" + }, + "hub_id": { + "type": "string", + "required": true, + "description": "Hub ID containing the room" + } + }, + "returns": { + "type": "object", + "description": "Room access context with peer and presenter information", + "properties": { + "presenter": { + "type": "object", + "description": "Current presenter data from room_get_presenter" + }, + "peers": { + "type": "array", + "description": "List of current room attendees excluding the requesting socket" + }, + "presenter_id": { + "type": "string", + "description": "User ID of the current presenter, or null if none" + }, + "room_id": { + "type": "string", + "description": "Room ID from the access record" + }, + "user": { + "type": "object", + "description": "Requesting user info (avatar_id, deviceId, role, ssid, uid, username)" + }, + "ssid": { + "type": "string", + "description": "Socket ID from the access record" + }, + "status": { + "type": "string", + "description": "Room access status" + }, + "peer": { + "type": "object", + "description": "Alias of user object for peer-to-peer signaling" + } + } + }, + "errors": [ + { + "code": "WEAK_PRIVILEGE OOOP", + "message": "User does not have sufficient permission to access the room", + "condition": "room_access returns no record or permission is falsy" + } + ] + }, + + "unified_room": { + "doc": "Manage unified room state for multi-participant sessions. Supports four operations via the flag parameter: get (retrieve room state), add (join with media settings using defaults), update (join with explicit required media settings), and remove (leave the unified room).", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "public-api" + }, + "params": { + "flag": { + "type": "string", + "required": true, + "description": "Operation to perform: 'get' to retrieve, 'add' to join with optional settings, 'update' to update with required settings, 'remove' to leave" + }, + "id": { + "type": "string", + "required": true, + "description": "Room ID to operate on" + }, + "uid": { + "type": "string", + "required": false, + "description": "User ID. Defaults to empty string if omitted." + }, + "is_mic_enabled": { + "type": "integer", + "required": false, + "description": "Microphone enabled state (0 or 1). Required when flag=update. Defaults to 1 when flag=add." + }, + "is_video_enabled": { + "type": "integer", + "required": false, + "description": "Camera enabled state (0 or 1). Required when flag=update. Defaults to 0 when flag=add." + }, + "is_share_enabled": { + "type": "integer", + "required": false, + "description": "Screen share enabled state (0 or 1). Required when flag=update. Defaults to 0 when flag=add." + }, + "is_write_enabled": { + "type": "integer", + "required": false, + "description": "Write/annotation enabled state (0 or 1). Required when flag=update. Defaults to 0 when flag=add." + }, + "metadata": { + "type": "object", + "required": false, + "description": "Additional room metadata object. Used when flag=add or flag=update. Defaults to empty object." + } + }, + "returns": { + "type": "array", + "description": "List of unified room records after the operation" + }, + "errors": [] + }, + + "update": { + "doc": "Update a scheduled meeting room. Supports partial or full updates controlled by the flag parameter. Flags: 'when' updates date only, 'title' updates title and renames the node, 'agenda' updates message, 'member' updates attendees and sends email invitations, 'all' updates all fields. Commits invitation emails to new attendees when flag includes member changes.", + "scope": "hub", + "permission": { + "src": "write", + "fast_check": "user_permission" + }, + "params": { + "flag": { + "type": "string", + "required": true, + "description": "Update scope: 'when' (date), 'title', 'agenda' (message), 'member' (attendees), or 'all' (all fields)" + }, + "nid": { + "type": "string", + "required": true, + "description": "Node ID of the meeting room to update" + }, + "date": { + "type": "string", + "required": false, + "description": "New meeting date/time string. Applied when flag is 'when' or 'all'." + }, + "stime": { + "type": "integer", + "required": false, + "description": "New meeting start time as UNIX-epoch seconds. Applied when flag is 'when' or 'all'; preserved if omitted." + }, + "etime": { + "type": "integer", + "required": false, + "description": "New meeting end time as UNIX-epoch seconds. Applied when flag is 'when' or 'all'; preserved if omitted." + }, + "title": { + "type": "string", + "required": false, + "description": "New meeting title. Applied when flag is 'title' or 'all'. Truncated to 100 characters." + }, + "message": { + "type": "string", + "required": false, + "description": "New meeting message/agenda. Applied when flag is 'agenda' or 'all'." + }, + "attendees": { + "type": "array", + "required": false, + "description": "Updated attendee list of WORKSPACE MEMBERS (applied when flag is 'member' or 'all'). Each entry is { uid, name } — no email/DMZ invite. Newly-added members get an in-app notification; visibility is workspace-wide." + }, + "recur": { + "type": "object", + "required": false, + "description": "Recurrence rule { freq: 'daily'|'weekly'|'monthly', until?: epoch-seconds } or null to clear. Flag-agnostic: applied whenever sent." + } + }, + "returns": { + "type": "object", + "description": "Updated meeting content fields", + "properties": { + "attendees": { + "type": "array", + "description": "Updated list of attendees" + }, + "title": { + "type": "string", + "description": "Updated meeting title" + }, + "message": { + "type": "string", + "description": "Updated meeting message/agenda" + }, + "date": { + "type": "string", + "description": "Updated meeting date/time" + } + } + }, + "errors": [] + }, + + "users": { + "doc": "List all users currently in a room. Reserved endpoint for room participant listing. Currently not active in the service implementation.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": {}, + "returns": { + "type": "array", + "description": "Reserved" + }, + "errors": [] + } + }, + "modules": { + "public": "service/room", + "private": "service/private/room" + } } \ No newline at end of file diff --git a/service/private/room.js b/service/private/room.js index 2456bb2..0d31f2e 100644 --- a/service/private/room.js +++ b/service/private/room.js @@ -18,6 +18,7 @@ const { isEmpty, isArray, difference, map } = require('lodash'); const __public_room = require("../room"); const { Attr, Privilege, Cache, sysEnv, RedisStore, toArray } = require("@drumee/server-essentials") const { memberCan, CAN_WRITE } = require("../lib/member-capability"); +const { normalizeNids, attachmentsOf, mergeAttachments, isMeetingNode } = require("../lib/meeting-attachments"); //######################################## class __private_room extends __public_room { @@ -102,6 +103,16 @@ class __private_room extends __public_room { await this.db.await_proc('permission_grant', nid, public_id, expiry, permission, 'link', '' ); + // The link opens the meeting; its attachments have to open too, or a guest + // sees file names they cannot download. Same identity and expiry as the + // meeting grant, download tier only (Privilege.download, never write). + const meeting = await this.db.await_proc('mfs_node_attr', nid); + const meta = this.parseJSON((meeting && meeting.metadata) || '{}') || {}; + for (const file_nid of attachmentsOf(meta.content)) { + await this.db.await_proc('permission_grant', + file_nid, public_id, expiry, Privilege.download, 'link', '' + ); + } this.debug("AAAA:104", p) let link = this._getShareLink(p.token) this.output.data({ link }); @@ -202,6 +213,10 @@ class __private_room extends __public_room { let stime = content.stime let etime = content.etime let recur = content.recur + // Not editable through update() — room.link_files owns the list — but it + // has to SURVIVE an edit: the Meet tab saves with flag 'all' and the + // content object below is rebuilt field by field. + const attachments = attachmentsOf(content); // The start time BEFORE this edit, so a real reschedule can be told apart // from a save that left the time alone. The scheduler posts the whole form @@ -262,7 +277,7 @@ class __private_room extends __public_room { nid, { content: { - attendees, title, message, date, stime, etime, recur, + attendees, title, message, date, stime, etime, recur, attachments, created_by: content.created_by, room_id: nid }, room_status: 'booked' @@ -339,7 +354,7 @@ class __private_room extends __public_room { } } content = { - attendees, title, message, date, stime, etime, recur, + attendees, title, message, date, stime, etime, recur, attachments, created_by: content.created_by }; // Keep the global reminder index in lockstep with the edited meeting @@ -348,6 +363,46 @@ class __private_room extends __public_room { await this.output.data((content)); } + /** + * Attach already-uploaded files to a meeting. Owner-only, like update(). + * Appends to metadata.content.attachments (deduped, capped — see + * lib/meeting-attachments). When the meeting already has a public link, the + * new files get the same download grant public_link gives (public_link + * itself grants whatever is attached at the time it runs). + * Params: nid (meeting), file_nids (array of media nids in this hub). + */ + async link_files() { + const nid = this.input.need(Attr.nid); + const incoming = normalizeNids(this.input.need('file_nids')); + const node = await this.db.await_proc('mfs_node_attr', nid); + if (!isMeetingNode(node)) return this.exception.user("MEETING_NOT_FOUND"); + const metadata = this.parseJSON(node.metadata || '{}') || {}; + const content = this.parseJSON(metadata.content || '{}') || {}; + if (content.created_by && content.created_by !== this.uid) { + return this.exception.user("NOT_MEETING_OWNER"); + } + const files = []; + for (const f of incoming) { + const a = await this.db.await_proc('mfs_node_attr', f); + if (!a || !(a.id || a.nid)) continue; + if (['folder', 'hub', 'root', 'schedule'].includes(a.filetype)) continue; + files.push(f); + } + const { list, added, overflow } = mergeAttachments(attachmentsOf(content), files); + await this.db.await_proc('mfs_set_metadata', nid, { + content: { ...content, attachments: list }, + room_status: metadata.room_status || 'booked', + }, 1); + const public_id = Cache.getSysConf('public_id'); + const pub = await this.db.await_proc('mfs_access_node', public_id, nid); + if (pub && Number(pub.privilege) > 0) { + for (const f of added) { + await this.db.await_proc('permission_grant', f, public_id, 0, Privilege.download, 'link', ''); + } + } + this.output.data({ nid, attachments: list, overflow }); + } + /** * Persist a scheduled-meeting notice for each recipient (Duy 2026-08-21, * issues 9 and 11). @@ -588,6 +643,13 @@ class __private_room extends __public_room { folder_name: await this._meeting_folder_name(node && node.parent_id), }); } + // The link identity's download grant on each attachment goes with the + // meeting. The files themselves stay in the hidden task folder (no + // reference counting for meetings yet). + const public_id = Cache.getSysConf('public_id'); + for (const file_nid of attachmentsOf(content)) { + await this.db.await_proc('permission_revoke', file_nid, public_id); + } await this.db.await_proc('permission_revoke', nid, "meeting"); await this._unindex_meeting(nid); this.output.data({ nid }); diff --git a/test/room-link-files.test.js b/test/room-link-files.test.js new file mode 100644 index 0000000..31a8742 --- /dev/null +++ b/test/room-link-files.test.js @@ -0,0 +1,145 @@ +/** + * Cover for room.link_files and the attachment-preserving edits to + * room.update / public_link / remove. + * Run: node test/room-link-files.test.js + */ +const assert = require("assert"); +const path = require("path"); + +const stub = (rel, exports) => { + const p = require.resolve(path.join(__dirname, "..", rel)); + require.cache[p] = { id: p, filename: p, loaded: true, exports }; +}; +const PUBLIC = "360deefd360def00"; +require.cache[require.resolve("@drumee/server-essentials")] = { + exports: { + Attr: { id: "id", nid: "nid", flag: "flag", password: "password", days: "days", hours: "hours", + permission: "permission", title: "title", date: "date", message: "message", stime: "stime", + etime: "etime", attendees: "attendees", profile: "profile" }, + Privilege: { write: 15, download: 7 }, + Cache: { getSysConf: (k) => (k === "public_id" ? PUBLIC : null), message: () => "" }, + sysEnv: () => ({}), RedisStore: {}, toArray: (v) => (Array.isArray(v) ? v : v == null ? [] : [v]), + }, +}; +stub("service/room.js", class {}); +stub("service/lib/member-capability.js", { memberCan: async () => true, CAN_WRITE: 2 }); +const Room = require("../service/private/room"); + +const MEET = "aaaaaaaaaaaaaaaa", F1 = "1111111111111111", F2 = "2222222222222222", OWNER = "uuuuuuuuuuuuuuuu"; + +function make({ input = {}, nodes = {}, publicPriv = 0, uid = OWNER } = {}) { + const calls = []; + const out = {}; + const self = Object.create(Room.prototype); + Object.assign(self, { + uid, + hub: { get: () => "hhhhhhhhhhhhhhhh" }, + user: { get: () => "Org", locale_message: () => ({ format: () => "" }) }, + input: { + need: (k) => { if (input[k] == null) throw new Error(`missing ${k}`); return input[k]; }, + use: (k, d) => (input[k] == null ? d : input[k]), + get: (k) => input[k], + }, + parseJSON: (v) => (typeof v === "string" ? JSON.parse(v) : v), + db: { + await_proc: async (name, ...args) => { + calls.push([name, ...args]); + if (name === "mfs_node_attr") return nodes[args[0]] || {}; + if (name === "mfs_access_node") return { privilege: publicPriv }; + return {}; + }, + }, + yp: { await_proc: async (name, ...args) => { calls.push([name, ...args]); return { token: "t" }; } }, + exception: { user: (code) => { out.error = code; } }, + output: { data: (d) => { out.data = d; } }, + randomString: () => "r", + debug: () => {}, + _index_meeting: async () => {}, + _unindex_meeting: async () => {}, + _meeting_folder_name: async () => "", + _getShareLink: () => "link", + }); + return { self, calls, out }; +} + +const meetingNode = (content) => ({ + id: MEET, filetype: "schedule", + metadata: JSON.stringify({ content: JSON.stringify(content), room_status: "booked" }), +}); +const fileNode = (id) => ({ id, filetype: "document", filename: "f" }); + +(async () => { + // link_files appends, writes metadata, no grant without a public link + { + const { self, calls, out } = make({ + input: { nid: MEET, file_nids: [F1, F2] }, + nodes: { [MEET]: meetingNode({ title: "M", created_by: OWNER }), [F1]: fileNode(F1), [F2]: fileNode(F2) }, + }); + await self.link_files(); + assert.deepStrictEqual(out.data, { nid: MEET, attachments: [F1, F2], overflow: [] }); + const set = calls.find((c) => c[0] === "mfs_set_metadata"); + assert.deepStrictEqual(set[2].content.attachments, [F1, F2]); + assert.strictEqual(set[2].content.title, "M"); + assert.ok(!calls.some((c) => c[0] === "permission_grant")); + } + // link_files mirrors an existing public grant onto the new files only + { + const { self, calls } = make({ + input: { nid: MEET, file_nids: [F1, F2] }, publicPriv: 15, + nodes: { [MEET]: meetingNode({ created_by: OWNER, attachments: [F1] }), [F1]: fileNode(F1), [F2]: fileNode(F2) }, + }); + await self.link_files(); + const grants = calls.filter((c) => c[0] === "permission_grant"); + assert.deepStrictEqual(grants.map((g) => g.slice(1)), [[F2, PUBLIC, 0, 7, "link", ""]]); + } + // link_files: not the owner / not a meeting / a folder nid + { + const a = make({ input: { nid: MEET, file_nids: [F1] }, uid: "someoneelse00000", + nodes: { [MEET]: meetingNode({ created_by: OWNER }) } }); + await a.self.link_files(); + assert.strictEqual(a.out.error, "NOT_MEETING_OWNER"); + const b = make({ input: { nid: F1, file_nids: [F2] }, nodes: { [F1]: fileNode(F1) } }); + await b.self.link_files(); + assert.strictEqual(b.out.error, "MEETING_NOT_FOUND"); + const c = make({ input: { nid: MEET, file_nids: [F1] }, + nodes: { [MEET]: meetingNode({ created_by: OWNER }), [F1]: { id: F1, filetype: "folder" } } }); + await c.self.link_files(); + assert.deepStrictEqual(c.out.data.attachments, []); + } + // update keeps attachments (Review Focus 1) + { + const { self, calls, out } = make({ + input: { nid: MEET, flag: "all", title: "New", message: "", attendees: [] }, + nodes: { [MEET]: meetingNode({ title: "Old", created_by: OWNER, attachments: [F1] }) }, + }); + await self.update(); + const set = calls.find((c) => c[0] === "mfs_set_metadata"); + assert.deepStrictEqual(set[2].content.attachments, [F1]); + assert.deepStrictEqual(out.data.attachments, [F1]); + } + // public_link grants every attachment with the link's expiry + { + const { self, calls } = make({ + input: { nid: MEET, hours: 2 }, + nodes: { [MEET]: meetingNode({ created_by: OWNER, attachments: [F1, F2] }) }, + }); + await self.public_link(); + const grants = calls.filter((c) => c[0] === "permission_grant").map((g) => g.slice(1)); + assert.deepStrictEqual(grants, [ + [MEET, PUBLIC, 2, 15, "link", ""], + [F1, PUBLIC, 2, 7, "link", ""], + [F2, PUBLIC, 2, 7, "link", ""], + ]); + } + // remove revokes the link identity's grant on each attachment + { + const { self, calls } = make({ + input: { nid: MEET }, + nodes: { [MEET]: meetingNode({ created_by: OWNER, attachments: [F1] }) }, + }); + await self.remove(); + const revokes = calls.filter((c) => c[0] === "permission_revoke").map((c) => c.slice(1)); + assert.deepStrictEqual(revokes, [[F1, PUBLIC], [MEET, "meeting"]]); + } + console.log("room-link-files: ok"); +})().catch((e) => { console.error(e); process.exit(1); }); From 2e6522b7aedd6e9abb65213805fd4d8d7fe86759 Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Thu, 1 Oct 2026 23:42:21 -0700 Subject: [PATCH 3/6] feat(dmz): meeting_files lists a meeting link's attachments by token Co-Authored-By: Claude Opus 5.5 (1M context) --- acl/dmz.json | 15 ++++++++ service/dmz.js | 64 +++++++++++++++++++++++++++++++ test/dmz-meeting-files.test.js | 69 ++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 test/dmz-meeting-files.test.js diff --git a/acl/dmz.json b/acl/dmz.json index d2b7a82..2eb3332 100644 --- a/acl/dmz.json +++ b/acl/dmz.json @@ -241,6 +241,21 @@ } ] }, + "meeting_files": { + "doc": "List a meeting share's attachments, authorised by the token alone. Answers names and sizes only; downloads go through file/orig under the link identity's grant. A password-protected link answers REQUIRED_PASSWORD until the session has passed the password.", + "scope": "hub", + "permission": { + "src": "anonymous", + "fast_check": "public-api" + }, + "params": { + "token": { + "type": "string", + "required": true, + "description": "Meeting share token" + } + } + }, "list_by_token": { "doc": "Read-only listing of a secure share's contents, authorised by the TOKEN alone. Exists because dmz.login refuses to bind a share identity onto a main-domain session (regsid guard), so a page served from the main domain can never obtain the grant media.show_node_by needs. This never touches the caller's session: no cookie_touch, no grant, no identity change. The listing target is derived from the token, never from client input. Refuses any share that is not plainly open — revoked, expired, invalid, locked, password-gated or email-gated all return a status and no items, because this endpoint performs no gate. A file share lists its parent folder hard-filtered to that file. Privileges are the anonymous guest's, clamped to the share's capabilities; sender-only fields are never echoed.", "scope": "hub", diff --git a/service/dmz.js b/service/dmz.js index 9024f49..989949d 100644 --- a/service/dmz.js +++ b/service/dmz.js @@ -22,6 +22,7 @@ const { } = Constants; const { verifyPassword: verifySecureSharePassword } = require('./lib/secure-share-password'); const { secureShareCapPrivilege } = require('./lib/secure-share-write-guard'); +const { attachmentsOf, isMeetingNode } = require('./lib/meeting-attachments'); const Jwt = require('jsonwebtoken'); const { resolve: _resolvePath } = require('path'); const { existsSync, readFileSync, statSync } = require('fs'); @@ -1474,6 +1475,69 @@ class __dmz extends Mfs { }); } + /** + * A meeting link's attachments, authorised by the TOKEN alone — the + * companion to list_by_token for a `schedule` share. Names only; the bytes + * come from file/orig, which room.public_link / room.link_files opened to the + * link identity. + * + * A password-protected link answers nothing until the session has passed the + * password (dmz.login → session.dmz_login), or the token alone would leak + * the file names the password is meant to protect. + * + * Every nid comes through attachmentsOf, which only admits /^[0-9a-f]{16}$/, + * so interpolating it into the forward_proc argument string is safe. + * + * Input: token {String} required + * Output: { status, hub_id?, items[] } + */ + async meeting_files() { + const token = this.input.need(Attr.token); + const deny = (status) => this.output.data({ status, items: [] }); + const share = await this._shareByToken(token, 'dmz.meeting_files'); + if (share.status) return deny(share.status); + const { info } = share; + const nid = info.node_id || info.nid; + const attr = async (id) => + toArray(await this.yp.await_proc('forward_proc', info.hub_id, 'mfs_node_attr', `'${id}'`))[0] || {}; + let node; + try { + node = await attr(nid); + } catch (e) { + this.warn('[dmz.meeting_files] meeting lookup failed:', e && e.message); + return deny('TICKET_INVALID'); + } + if (!isMeetingNode(node)) return deny('NOT_A_MEETING'); + if (info.require_password) { + const access = (await this.db.await_proc('mfs_access_node', this.uid, nid)) || {}; + if (!access.privilege) return deny('REQUIRED_PASSWORD'); + } + let meta = {}; + try { + meta = JSON.parse(node.metadata || '{}') || {}; + } catch (e) { + meta = {}; + } + const items = []; + for (const id of attachmentsOf(meta.content)) { + let a; + try { + a = await attr(id); + } catch (e) { + continue; + } + if (!a || !(a.id || a.nid)) continue; + items.push({ + nid: a.id || a.nid, + filename: a.filename || a.user_filename || '', + ext: a.ext || a.extension || '', + filetype: a.filetype || a.ftype || '', + filesize: a.filesize || 0, + }); + } + this.output.data({ status: 'TICKET_OK', hub_id: info.hub_id, items }); + } + /** * Read-only workspace chat for a share, authorised BY THE TOKEN ALONE. * diff --git a/test/dmz-meeting-files.test.js b/test/dmz-meeting-files.test.js new file mode 100644 index 0000000..6931a83 --- /dev/null +++ b/test/dmz-meeting-files.test.js @@ -0,0 +1,69 @@ +/** + * Cover for dmz.meeting_files. + * Run: node test/dmz-meeting-files.test.js + */ +const assert = require("assert"); +const path = require("path"); +const fs = require("fs"); + +// dmz.js has a heavy require graph; slice the one method out and run it +// against a stub `this` (the ui-team tests/helpers/slice-method technique). +const SRC = fs.readFileSync(path.join(__dirname, "../service/dmz.js"), "utf8"); +function slice(sig) { + const start = SRC.indexOf(`\n ${sig} {`); + if (start < 0) throw new Error(`${sig} not found`); + const end = SRC.indexOf("\n }\n", start); + return SRC.slice(start, end + 4).trim().replace(/^async\s+([A-Za-z_]\w*)\s*\(/, "async function $1("); +} +const lib = require("../service/lib/meeting-attachments"); +const toArray = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]); +const meeting_files = new Function("toArray", "attachmentsOf", "isMeetingNode", "Attr", + `return ${slice("async meeting_files()")}`)(toArray, lib.attachmentsOf, lib.isMeetingNode, { token: "token" }); + +const MEET = "aaaaaaaaaaaaaaaa", F1 = "1111111111111111"; +function make({ share, nodes = {}, viewerPriv = 0 }) { + const out = {}; + const self = { + uid: "vvvvvvvvvvvvvvvv", + input: { need: () => "tok" }, + _shareByToken: async () => share, + yp: { await_proc: async (_p, _hub, proc, args) => { + const id = String(args).replace(/'/g, ""); + return proc === "mfs_node_attr" ? [nodes[id] || {}] : []; + } }, + db: { await_proc: async () => ({ privilege: viewerPriv }) }, + output: { data: (d) => { out.data = d; } }, + warn: () => {}, + }; + return { run: () => meeting_files.call(self), out }; +} +const meetingNode = (attachments) => ({ id: MEET, filetype: "schedule", + metadata: JSON.stringify({ content: JSON.stringify({ attachments }) }) }); + +(async () => { + { + const t = make({ share: { info: { hub_id: "h", nid: MEET } }, + nodes: { [MEET]: meetingNode([F1]), [F1]: { id: F1, filename: "a", ext: "pdf", filetype: "document", filesize: 9, owner_id: "secret" } } }); + await t.run(); + assert.deepStrictEqual(t.out.data, { status: "TICKET_OK", hub_id: "h", + items: [{ nid: F1, filename: "a", ext: "pdf", filetype: "document", filesize: 9 }] }); + } + { + const t = make({ share: { status: "TICKET_INVALID" } }); + await t.run(); + assert.deepStrictEqual(t.out.data, { status: "TICKET_INVALID", items: [] }); + } + { + const t = make({ share: { info: { hub_id: "h", nid: F1 } }, nodes: { [F1]: { id: F1, filetype: "folder" } } }); + await t.run(); + assert.deepStrictEqual(t.out.data, { status: "NOT_A_MEETING", items: [] }); + } + // Review Focus 4: password-protected link without access lists nothing + { + const t = make({ share: { info: { hub_id: "h", nid: MEET, require_password: 1 } }, + nodes: { [MEET]: meetingNode([F1]) }, viewerPriv: 0 }); + await t.run(); + assert.deepStrictEqual(t.out.data, { status: "REQUIRED_PASSWORD", items: [] }); + } + console.log("dmz-meeting-files: ok"); +})().catch((e) => { console.error(e); process.exit(1); }); From 68132473c454af6b3d64b42dd11f1db13505fd21 Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Fri, 2 Oct 2026 00:02:35 -0700 Subject: [PATCH 4/6] fix(acl): keep room.json's CRLF line endings Co-Authored-By: Claude Opus 5.5 (1M context) --- acl/room.json | 1598 ++++++++++++++++++++++++------------------------- 1 file changed, 799 insertions(+), 799 deletions(-) diff --git a/acl/room.json b/acl/room.json index f7a3806..3eb3106 100644 --- a/acl/room.json +++ b/acl/room.json @@ -1,800 +1,800 @@ -{ - "services": { - "book": { - "doc": "Book a new meeting room. Creates a scheduled meeting node (category=schedule, ext=schedule) in the user home directory with attendees, title, message, and date metadata. Title defaults to a locale-generated headline if not provided. Date defaults to current time if not provided.", - "scope": "hub", - "permission": { - "src": "write", - "fast_check": "user_permission" - }, - "params": { - "title": { - "type": "string", - "required": false, - "description": "Meeting title. Auto-generated from locale template if omitted. Truncated to 100 characters maximum." - }, - "date": { - "type": "string", - "required": false, - "description": "Meeting date/time string. Defaults to current moment formatted as locale long date-time if omitted." - }, - "stime": { - "type": "integer", - "required": false, - "description": "Meeting start time as UNIX-epoch seconds. Canonical, range-queryable time (see room.list). Stored in metadata alongside the display date." - }, - "etime": { - "type": "integer", - "required": false, - "description": "Meeting end time as UNIX-epoch seconds. Canonical, range-queryable time (see room.list)." - }, - "recur": { - "type": "object", - "required": false, - "description": "Recurrence rule { freq: 'daily'|'weekly'|'monthly', until?: epoch-seconds }, or null for a one-off. Stored in metadata; occurrences are expanded client-side on the calendar." - }, - "message": { - "type": "string", - "required": false, - "description": "Meeting invitation message body. Auto-generated from locale template if omitted." - } - }, - "returns": { - "type": "object", - "description": "Created MFS node representing the scheduled meeting", - "properties": { - "id": { - "type": "string", - "description": "Node ID of the created meeting schedule node" - }, - "filename": { - "type": "string", - "description": "Meeting title stored as the node filename" - }, - "category": { - "type": "string", - "description": "Always 'schedule' for meeting nodes" - }, - "metadata": { - "type": "object", - "description": "Meeting metadata including content (attendees, title, message, date, room_id) and room_status=booked" - } - } - }, - "errors": [] - }, - - "get_meeting_members": { - "doc": "Get all members invited to a meeting room. Returns the list of DMZ users who have been granted access to a specific meeting node.", - "scope": "hub", - "permission": { - "src": "admin" - }, - "params": { - "nid": { - "type": "string", - "required": true, - "description": "Node ID of the meeting schedule to retrieve members for" - } - }, - "returns": { - "type": "array", - "description": "List of meeting members with their access details", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Member user ID" - }, - "email": { - "type": "string", - "description": "Member email address" - }, - "name": { - "type": "string", - "description": "Member display name" - } - } - } - }, - "errors": [] - }, - - "get_screen": { - "doc": "Initialize a screen sharing session for a room. Generates a unique screen_id, grants permission on it, and sends room_invite_next to all current room attendees assigning the requester as presenter and others as listeners. Only available for non-private hub areas.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": { - "parent_id": { - "type": "string", - "required": true, - "description": "ID of the parent room for which screen sharing is being initiated" - }, - "parent_type": { - "type": "string", - "required": true, - "description": "Type of the parent room (e.g. room type identifier)" - }, - "socket_id": { - "type": "string", - "required": true, - "description": "WebSocket socket ID of the requesting user" - }, - "device_id": { - "type": "string", - "required": true, - "description": "Device ID of the requesting user" - } - }, - "returns": { - "type": "object", - "description": "New screen sharing room data", - "properties": { - "id": { - "type": "string", - "description": "Generated screen room ID" - }, - "type": { - "type": "string", - "description": "Always 'screen' for screen sharing rooms" - }, - "socket_id": { - "type": "string", - "description": "Socket ID of the session" - } - } - }, - "errors": [] - }, - - "get": { - "doc": "Get or create a room for the current user. Retrieves an existing room matching the given device, socket, and room type, or creates a new one if none exists. Mapped to the get_or_create implementation method.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "method": "get_or_create", - "params": { - "device_id": { - "type": "string", - "required": true, - "description": "Device ID of the user" - }, - "socket_id": { - "type": "string", - "required": true, - "description": "WebSocket socket ID of the user" - }, - "room_type": { - "type": "string", - "required": true, - "description": "Type of room to get or create (e.g. meeting, screen)" - }, - "id": { - "type": "string", - "required": false, - "description": "Specific room ID to look up. If omitted, a new room is created." - } - }, - "returns": { - "type": "object", - "description": "Room record retrieved or created", - "properties": { - "id": { - "type": "string", - "description": "Room ID" - }, - "type": { - "type": "string", - "description": "Room type" - }, - "user_id": { - "type": "string", - "description": "Owner user ID" - }, - "socket_id": { - "type": "string", - "description": "Socket ID associated with the room" - }, - "device_id": { - "type": "string", - "description": "Device ID associated with the room" - } - } - }, - "errors": [] - }, - - "hello": { - "doc": "Register a guest user session for public meeting access. Binds a guest name to the current session cookie via cookie_bind_guest. Used as the entry point for unauthenticated users joining a shared meeting link.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "public-api" - }, - "params": { - "name": { - "type": "string", - "required": true, - "description": "Display name for the guest user joining the meeting" - } - }, - "returns": { - "type": "object", - "description": "Empty confirmation object on successful guest session binding" - }, - "errors": [] - }, - - "invite": { - "doc": "Invite a peer to join an active room. Sends a room_invite_next event to the target guest via WebSocket (signaling.message) with linkup type, assigning them the listener role. Notifies the guest socket on their endpoint node.", - "scope": "hub", - "permission": { - "src": "write", - "fast_check": "user_permission" - }, - "params": { - "room_id": { - "type": "string", - "required": true, - "description": "ID of the room to invite the guest into" - }, - "guest": { - "type": "object", - "required": true, - "description": "Guest user object containing uid, socket_id, device_id, and optionally node (endpoint address)" - }, - "room_type": { - "type": "string", - "required": true, - "description": "Type of the room being joined" - } - }, - "returns": { - "type": "array", - "description": "List containing guest connection info and signaling data payload", - "items": { - "type": "object", - "properties": { - "uid": { - "type": "string", - "description": "Guest user ID" - }, - "socket_id": { - "type": "string", - "description": "Guest socket ID" - }, - "type": { - "type": "string", - "description": "Signaling type, always 'linkup'" - }, - "service": { - "type": "string", - "description": "Signaling service identifier, always 'signaling.message'" - } - } - } - }, - "errors": [] - }, - - "join": { - "doc": "Join an active room and notify the hub owner. Retrieves or creates a room entry, attaches hub area and node details, then sends a WebSocket notification to the hub owner's sockets. Returns room data including current attendees list.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": { - "id": { - "type": "string", - "required": true, - "description": "Room node ID to join" - }, - "socket_id": { - "type": "string", - "required": true, - "description": "WebSocket socket ID of the joining user" - }, - "device_id": { - "type": "string", - "required": true, - "description": "Device ID of the joining user" - }, - "room_type": { - "type": "string", - "required": true, - "description": "Type of the room being joined" - }, - "endpointAddress": { - "type": "string", - "required": false, - "description": "Server endpoint address. Falls back to Cache environment value if omitted." - }, - "endpointRoute": { - "type": "string", - "required": false, - "description": "Server endpoint route. Falls back to Cache environment value if omitted." - } - }, - "returns": { - "type": "object", - "description": "Room data with hub context and attendees", - "properties": { - "wicket_id": { - "type": "string", - "description": "Hub owner ID" - }, - "area": { - "type": "string", - "description": "Hub area (private, dmz, etc.)" - }, - "details": { - "type": "object", - "description": "Sanitized MFS node attributes for the room node" - }, - "attendees": { - "type": "array", - "description": "Current list of room attendees" - } - } - }, - "errors": [] - }, - - "leave": { - "doc": "Leave an active room and notify remaining peers. Removes the user from the room via room_leave_next, pushes user online status to 1 (online/idle), and sends a room.leave signaling message to all remaining attendees via WebSocket.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": { - "socket_id": { - "type": "string", - "required": true, - "description": "WebSocket socket ID of the leaving user" - }, - "room_id": { - "type": "string", - "required": true, - "description": "ID of the room being left" - }, - "hub_id": { - "type": "string", - "required": true, - "description": "Hub ID containing the room" - } - }, - "returns": { - "type": "object", - "description": "Unified room data after the user has left" - }, - "errors": [] - }, - - "link_files": { - "doc": "Attach already-uploaded files (media nids in this hub) to a meeting the caller created. Appends to the meeting's attachment list (deduped, max 20) and, when the meeting already has a public link, grants that link download access to the new files. Returns the full list and any nids refused for the cap.", - "scope": "hub", - "permission": { - "src": "write", - "fast_check": "user_permission" - }, - "params": { - "nid": { - "type": "string", - "required": true, - "description": "Meeting (schedule) node id" - }, - "file_nids": { - "type": "array", - "required": true, - "description": "Media node ids to attach" - } - } - }, - "public_link": { - "doc": "Generate a public shareable link for a meeting room node. Creates a DMZ grant token for the specified node, grants permission to the public_id system user, and returns a shareable URL containing the access token.", - "scope": "hub", - "permission": { - "src": "admin" - }, - "params": { - "nid": { - "type": "string", - "required": true, - "description": "Node ID of the meeting room to generate a public link for" - }, - "password": { - "type": "string", - "required": false, - "description": "Optional password to protect the public link" - }, - "days": { - "type": "integer", - "required": false, - "default": 0, - "description": "Number of days for link expiry. Combined with hours to compute total expiry in hours." - }, - "hours": { - "type": "integer", - "required": false, - "default": 0, - "description": "Number of hours for link expiry. Combined with days to compute total expiry." - }, - "permission": { - "type": "integer", - "required": false, - "description": "Permission level to grant on the node. Defaults to Privilege.write if omitted." - } - }, - "returns": { - "type": "object", - "description": "Public shareable link for the meeting room", - "properties": { - "link": { - "type": "string", - "description": "Full shareable URL with DMZ access token appended as path segment" - } - } - }, - "errors": [] - }, - - "shutdown": { - "doc": "Shut down an active room session. Reserved for admin-level room lifecycle management. Returns an empty object on completion.", - "scope": "hub", - "permission": { - "src": "admin" - }, - "params": { - "room_id": { - "type": "string", - "required": true, - "description": "ID of the room to shut down" - } - }, - "returns": { - "type": "object", - "description": "Empty confirmation object" - }, - "errors": [] - }, - - "check_availability": { - "doc": "Free/busy for a proposed slot (workspace-scoped). Given attendee uids + [stime,etime], returns which invitees already have a meeting in this hub overlapping the slot. Warn-only; the organizer can still book.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": { - "stime": { "type": "integer", "required": true, "description": "Proposed start (epoch seconds)" }, - "etime": { "type": "integer", "required": true, "description": "Proposed end (epoch seconds)" }, - "attendees": { "type": "array", "required": false, "description": "Invitee list [{uid,name}] to check" }, - "nid": { "type": "string", "required": false, "description": "Meeting being edited, excluded from its own check" } - }, - "returns": { - "type": "array", - "description": "Per-invitee availability", - "items": { - "type": "object", - "properties": { - "uid": { "type": "string" }, - "busy": { "type": "boolean" }, - "conflicts": { "type": "array", "description": "Overlapping meetings [{nid,title,stime,etime}]" } - } - } - }, - "errors": [] - }, - - "list": { - "doc": "List the current hub's scheduled meetings whose time window overlaps [stime, etime]. Reads schedule nodes (category=schedule) via room_list_scheduled, filtering on the canonical UNIX-epoch time persisted in node metadata. Both bounds optional; omit to return every scheduled meeting. Feeds the folder-window meeting calendar.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": { - "stime": { - "type": "integer", - "required": false, - "description": "Range start as UNIX-epoch seconds. Omit (with etime) for no time filter." - }, - "etime": { - "type": "integer", - "required": false, - "description": "Range end as UNIX-epoch seconds. Omit (with stime) for no time filter." - } - }, - "returns": { - "type": "array", - "description": "Scheduled meetings in range, ordered by start time", - "items": { - "type": "object", - "properties": { - "id": { "type": "string", "description": "Meeting node id (also room_id)" }, - "filename": { "type": "string", "description": "Meeting title" }, - "stime": { "type": "integer", "description": "Start time (epoch seconds)" }, - "etime": { "type": "integer", "description": "End time (epoch seconds)" }, - "metadata": { "type": "object", "description": "Full node metadata (content: attendees/title/message/date/stime/etime/room_id)" } - } - } - }, - "errors": [] - }, - - "remove": { - "doc": "Remove a meeting room and revoke all permissions. Revokes all permissions on the meeting node using permission_revoke with scope 'meeting' and returns the node ID as confirmation.", - "scope": "hub", - "permission": { - "src": "write", - "fast_check": "user_permission" - }, - "params": { - "nid": { - "type": "string", - "required": true, - "description": "Node ID of the meeting room to remove" - } - }, - "returns": { - "type": "object", - "description": "Confirmation of removal", - "properties": { - "nid": { - "type": "string", - "description": "Node ID of the removed meeting room" - } - } - }, - "errors": [] - }, - - "request_screen_access": { - "doc": "Request access to a peer screen sharing session. Reserved endpoint for future screen access request workflow. Currently not active in the service implementation.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": {}, - "returns": { - "type": "object", - "description": "Reserved" - }, - "errors": [] - }, - - "requestAccess": { - "doc": "Request access to join an active room as a participant. Verifies room permission via room_access, retrieves presenter and current peers, pushes user online status to 2 (in-meeting), and if the requester is the presenter sends a meeting.start signal to all peers.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "public-api" - }, - "params": { - "socket_id": { - "type": "string", - "required": true, - "description": "WebSocket socket ID of the requesting user" - }, - "room_id": { - "type": "string", - "required": true, - "description": "ID of the room to request access to" - }, - "hub_id": { - "type": "string", - "required": true, - "description": "Hub ID containing the room" - } - }, - "returns": { - "type": "object", - "description": "Room access context with peer and presenter information", - "properties": { - "presenter": { - "type": "object", - "description": "Current presenter data from room_get_presenter" - }, - "peers": { - "type": "array", - "description": "List of current room attendees excluding the requesting socket" - }, - "presenter_id": { - "type": "string", - "description": "User ID of the current presenter, or null if none" - }, - "room_id": { - "type": "string", - "description": "Room ID from the access record" - }, - "user": { - "type": "object", - "description": "Requesting user info (avatar_id, deviceId, role, ssid, uid, username)" - }, - "ssid": { - "type": "string", - "description": "Socket ID from the access record" - }, - "status": { - "type": "string", - "description": "Room access status" - }, - "peer": { - "type": "object", - "description": "Alias of user object for peer-to-peer signaling" - } - } - }, - "errors": [ - { - "code": "WEAK_PRIVILEGE OOOP", - "message": "User does not have sufficient permission to access the room", - "condition": "room_access returns no record or permission is falsy" - } - ] - }, - - "unified_room": { - "doc": "Manage unified room state for multi-participant sessions. Supports four operations via the flag parameter: get (retrieve room state), add (join with media settings using defaults), update (join with explicit required media settings), and remove (leave the unified room).", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "public-api" - }, - "params": { - "flag": { - "type": "string", - "required": true, - "description": "Operation to perform: 'get' to retrieve, 'add' to join with optional settings, 'update' to update with required settings, 'remove' to leave" - }, - "id": { - "type": "string", - "required": true, - "description": "Room ID to operate on" - }, - "uid": { - "type": "string", - "required": false, - "description": "User ID. Defaults to empty string if omitted." - }, - "is_mic_enabled": { - "type": "integer", - "required": false, - "description": "Microphone enabled state (0 or 1). Required when flag=update. Defaults to 1 when flag=add." - }, - "is_video_enabled": { - "type": "integer", - "required": false, - "description": "Camera enabled state (0 or 1). Required when flag=update. Defaults to 0 when flag=add." - }, - "is_share_enabled": { - "type": "integer", - "required": false, - "description": "Screen share enabled state (0 or 1). Required when flag=update. Defaults to 0 when flag=add." - }, - "is_write_enabled": { - "type": "integer", - "required": false, - "description": "Write/annotation enabled state (0 or 1). Required when flag=update. Defaults to 0 when flag=add." - }, - "metadata": { - "type": "object", - "required": false, - "description": "Additional room metadata object. Used when flag=add or flag=update. Defaults to empty object." - } - }, - "returns": { - "type": "array", - "description": "List of unified room records after the operation" - }, - "errors": [] - }, - - "update": { - "doc": "Update a scheduled meeting room. Supports partial or full updates controlled by the flag parameter. Flags: 'when' updates date only, 'title' updates title and renames the node, 'agenda' updates message, 'member' updates attendees and sends email invitations, 'all' updates all fields. Commits invitation emails to new attendees when flag includes member changes.", - "scope": "hub", - "permission": { - "src": "write", - "fast_check": "user_permission" - }, - "params": { - "flag": { - "type": "string", - "required": true, - "description": "Update scope: 'when' (date), 'title', 'agenda' (message), 'member' (attendees), or 'all' (all fields)" - }, - "nid": { - "type": "string", - "required": true, - "description": "Node ID of the meeting room to update" - }, - "date": { - "type": "string", - "required": false, - "description": "New meeting date/time string. Applied when flag is 'when' or 'all'." - }, - "stime": { - "type": "integer", - "required": false, - "description": "New meeting start time as UNIX-epoch seconds. Applied when flag is 'when' or 'all'; preserved if omitted." - }, - "etime": { - "type": "integer", - "required": false, - "description": "New meeting end time as UNIX-epoch seconds. Applied when flag is 'when' or 'all'; preserved if omitted." - }, - "title": { - "type": "string", - "required": false, - "description": "New meeting title. Applied when flag is 'title' or 'all'. Truncated to 100 characters." - }, - "message": { - "type": "string", - "required": false, - "description": "New meeting message/agenda. Applied when flag is 'agenda' or 'all'." - }, - "attendees": { - "type": "array", - "required": false, - "description": "Updated attendee list of WORKSPACE MEMBERS (applied when flag is 'member' or 'all'). Each entry is { uid, name } — no email/DMZ invite. Newly-added members get an in-app notification; visibility is workspace-wide." - }, - "recur": { - "type": "object", - "required": false, - "description": "Recurrence rule { freq: 'daily'|'weekly'|'monthly', until?: epoch-seconds } or null to clear. Flag-agnostic: applied whenever sent." - } - }, - "returns": { - "type": "object", - "description": "Updated meeting content fields", - "properties": { - "attendees": { - "type": "array", - "description": "Updated list of attendees" - }, - "title": { - "type": "string", - "description": "Updated meeting title" - }, - "message": { - "type": "string", - "description": "Updated meeting message/agenda" - }, - "date": { - "type": "string", - "description": "Updated meeting date/time" - } - } - }, - "errors": [] - }, - - "users": { - "doc": "List all users currently in a room. Reserved endpoint for room participant listing. Currently not active in the service implementation.", - "scope": "hub", - "permission": { - "src": "read", - "fast_check": "user_permission" - }, - "params": {}, - "returns": { - "type": "array", - "description": "Reserved" - }, - "errors": [] - } - }, - "modules": { - "public": "service/room", - "private": "service/private/room" - } +{ + "services": { + "book": { + "doc": "Book a new meeting room. Creates a scheduled meeting node (category=schedule, ext=schedule) in the user home directory with attendees, title, message, and date metadata. Title defaults to a locale-generated headline if not provided. Date defaults to current time if not provided.", + "scope": "hub", + "permission": { + "src": "write", + "fast_check": "user_permission" + }, + "params": { + "title": { + "type": "string", + "required": false, + "description": "Meeting title. Auto-generated from locale template if omitted. Truncated to 100 characters maximum." + }, + "date": { + "type": "string", + "required": false, + "description": "Meeting date/time string. Defaults to current moment formatted as locale long date-time if omitted." + }, + "stime": { + "type": "integer", + "required": false, + "description": "Meeting start time as UNIX-epoch seconds. Canonical, range-queryable time (see room.list). Stored in metadata alongside the display date." + }, + "etime": { + "type": "integer", + "required": false, + "description": "Meeting end time as UNIX-epoch seconds. Canonical, range-queryable time (see room.list)." + }, + "recur": { + "type": "object", + "required": false, + "description": "Recurrence rule { freq: 'daily'|'weekly'|'monthly', until?: epoch-seconds }, or null for a one-off. Stored in metadata; occurrences are expanded client-side on the calendar." + }, + "message": { + "type": "string", + "required": false, + "description": "Meeting invitation message body. Auto-generated from locale template if omitted." + } + }, + "returns": { + "type": "object", + "description": "Created MFS node representing the scheduled meeting", + "properties": { + "id": { + "type": "string", + "description": "Node ID of the created meeting schedule node" + }, + "filename": { + "type": "string", + "description": "Meeting title stored as the node filename" + }, + "category": { + "type": "string", + "description": "Always 'schedule' for meeting nodes" + }, + "metadata": { + "type": "object", + "description": "Meeting metadata including content (attendees, title, message, date, room_id) and room_status=booked" + } + } + }, + "errors": [] + }, + + "get_meeting_members": { + "doc": "Get all members invited to a meeting room. Returns the list of DMZ users who have been granted access to a specific meeting node.", + "scope": "hub", + "permission": { + "src": "admin" + }, + "params": { + "nid": { + "type": "string", + "required": true, + "description": "Node ID of the meeting schedule to retrieve members for" + } + }, + "returns": { + "type": "array", + "description": "List of meeting members with their access details", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Member user ID" + }, + "email": { + "type": "string", + "description": "Member email address" + }, + "name": { + "type": "string", + "description": "Member display name" + } + } + } + }, + "errors": [] + }, + + "get_screen": { + "doc": "Initialize a screen sharing session for a room. Generates a unique screen_id, grants permission on it, and sends room_invite_next to all current room attendees assigning the requester as presenter and others as listeners. Only available for non-private hub areas.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": { + "parent_id": { + "type": "string", + "required": true, + "description": "ID of the parent room for which screen sharing is being initiated" + }, + "parent_type": { + "type": "string", + "required": true, + "description": "Type of the parent room (e.g. room type identifier)" + }, + "socket_id": { + "type": "string", + "required": true, + "description": "WebSocket socket ID of the requesting user" + }, + "device_id": { + "type": "string", + "required": true, + "description": "Device ID of the requesting user" + } + }, + "returns": { + "type": "object", + "description": "New screen sharing room data", + "properties": { + "id": { + "type": "string", + "description": "Generated screen room ID" + }, + "type": { + "type": "string", + "description": "Always 'screen' for screen sharing rooms" + }, + "socket_id": { + "type": "string", + "description": "Socket ID of the session" + } + } + }, + "errors": [] + }, + + "get": { + "doc": "Get or create a room for the current user. Retrieves an existing room matching the given device, socket, and room type, or creates a new one if none exists. Mapped to the get_or_create implementation method.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "method": "get_or_create", + "params": { + "device_id": { + "type": "string", + "required": true, + "description": "Device ID of the user" + }, + "socket_id": { + "type": "string", + "required": true, + "description": "WebSocket socket ID of the user" + }, + "room_type": { + "type": "string", + "required": true, + "description": "Type of room to get or create (e.g. meeting, screen)" + }, + "id": { + "type": "string", + "required": false, + "description": "Specific room ID to look up. If omitted, a new room is created." + } + }, + "returns": { + "type": "object", + "description": "Room record retrieved or created", + "properties": { + "id": { + "type": "string", + "description": "Room ID" + }, + "type": { + "type": "string", + "description": "Room type" + }, + "user_id": { + "type": "string", + "description": "Owner user ID" + }, + "socket_id": { + "type": "string", + "description": "Socket ID associated with the room" + }, + "device_id": { + "type": "string", + "description": "Device ID associated with the room" + } + } + }, + "errors": [] + }, + + "hello": { + "doc": "Register a guest user session for public meeting access. Binds a guest name to the current session cookie via cookie_bind_guest. Used as the entry point for unauthenticated users joining a shared meeting link.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "public-api" + }, + "params": { + "name": { + "type": "string", + "required": true, + "description": "Display name for the guest user joining the meeting" + } + }, + "returns": { + "type": "object", + "description": "Empty confirmation object on successful guest session binding" + }, + "errors": [] + }, + + "invite": { + "doc": "Invite a peer to join an active room. Sends a room_invite_next event to the target guest via WebSocket (signaling.message) with linkup type, assigning them the listener role. Notifies the guest socket on their endpoint node.", + "scope": "hub", + "permission": { + "src": "write", + "fast_check": "user_permission" + }, + "params": { + "room_id": { + "type": "string", + "required": true, + "description": "ID of the room to invite the guest into" + }, + "guest": { + "type": "object", + "required": true, + "description": "Guest user object containing uid, socket_id, device_id, and optionally node (endpoint address)" + }, + "room_type": { + "type": "string", + "required": true, + "description": "Type of the room being joined" + } + }, + "returns": { + "type": "array", + "description": "List containing guest connection info and signaling data payload", + "items": { + "type": "object", + "properties": { + "uid": { + "type": "string", + "description": "Guest user ID" + }, + "socket_id": { + "type": "string", + "description": "Guest socket ID" + }, + "type": { + "type": "string", + "description": "Signaling type, always 'linkup'" + }, + "service": { + "type": "string", + "description": "Signaling service identifier, always 'signaling.message'" + } + } + } + }, + "errors": [] + }, + + "join": { + "doc": "Join an active room and notify the hub owner. Retrieves or creates a room entry, attaches hub area and node details, then sends a WebSocket notification to the hub owner's sockets. Returns room data including current attendees list.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": { + "id": { + "type": "string", + "required": true, + "description": "Room node ID to join" + }, + "socket_id": { + "type": "string", + "required": true, + "description": "WebSocket socket ID of the joining user" + }, + "device_id": { + "type": "string", + "required": true, + "description": "Device ID of the joining user" + }, + "room_type": { + "type": "string", + "required": true, + "description": "Type of the room being joined" + }, + "endpointAddress": { + "type": "string", + "required": false, + "description": "Server endpoint address. Falls back to Cache environment value if omitted." + }, + "endpointRoute": { + "type": "string", + "required": false, + "description": "Server endpoint route. Falls back to Cache environment value if omitted." + } + }, + "returns": { + "type": "object", + "description": "Room data with hub context and attendees", + "properties": { + "wicket_id": { + "type": "string", + "description": "Hub owner ID" + }, + "area": { + "type": "string", + "description": "Hub area (private, dmz, etc.)" + }, + "details": { + "type": "object", + "description": "Sanitized MFS node attributes for the room node" + }, + "attendees": { + "type": "array", + "description": "Current list of room attendees" + } + } + }, + "errors": [] + }, + + "leave": { + "doc": "Leave an active room and notify remaining peers. Removes the user from the room via room_leave_next, pushes user online status to 1 (online/idle), and sends a room.leave signaling message to all remaining attendees via WebSocket.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": { + "socket_id": { + "type": "string", + "required": true, + "description": "WebSocket socket ID of the leaving user" + }, + "room_id": { + "type": "string", + "required": true, + "description": "ID of the room being left" + }, + "hub_id": { + "type": "string", + "required": true, + "description": "Hub ID containing the room" + } + }, + "returns": { + "type": "object", + "description": "Unified room data after the user has left" + }, + "errors": [] + }, + + "link_files": { + "doc": "Attach already-uploaded files (media nids in this hub) to a meeting the caller created. Appends to the meeting's attachment list (deduped, max 20) and, when the meeting already has a public link, grants that link download access to the new files. Returns the full list and any nids refused for the cap.", + "scope": "hub", + "permission": { + "src": "write", + "fast_check": "user_permission" + }, + "params": { + "nid": { + "type": "string", + "required": true, + "description": "Meeting (schedule) node id" + }, + "file_nids": { + "type": "array", + "required": true, + "description": "Media node ids to attach" + } + } + }, + "public_link": { + "doc": "Generate a public shareable link for a meeting room node. Creates a DMZ grant token for the specified node, grants permission to the public_id system user, and returns a shareable URL containing the access token.", + "scope": "hub", + "permission": { + "src": "admin" + }, + "params": { + "nid": { + "type": "string", + "required": true, + "description": "Node ID of the meeting room to generate a public link for" + }, + "password": { + "type": "string", + "required": false, + "description": "Optional password to protect the public link" + }, + "days": { + "type": "integer", + "required": false, + "default": 0, + "description": "Number of days for link expiry. Combined with hours to compute total expiry in hours." + }, + "hours": { + "type": "integer", + "required": false, + "default": 0, + "description": "Number of hours for link expiry. Combined with days to compute total expiry." + }, + "permission": { + "type": "integer", + "required": false, + "description": "Permission level to grant on the node. Defaults to Privilege.write if omitted." + } + }, + "returns": { + "type": "object", + "description": "Public shareable link for the meeting room", + "properties": { + "link": { + "type": "string", + "description": "Full shareable URL with DMZ access token appended as path segment" + } + } + }, + "errors": [] + }, + + "shutdown": { + "doc": "Shut down an active room session. Reserved for admin-level room lifecycle management. Returns an empty object on completion.", + "scope": "hub", + "permission": { + "src": "admin" + }, + "params": { + "room_id": { + "type": "string", + "required": true, + "description": "ID of the room to shut down" + } + }, + "returns": { + "type": "object", + "description": "Empty confirmation object" + }, + "errors": [] + }, + + "check_availability": { + "doc": "Free/busy for a proposed slot (workspace-scoped). Given attendee uids + [stime,etime], returns which invitees already have a meeting in this hub overlapping the slot. Warn-only; the organizer can still book.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": { + "stime": { "type": "integer", "required": true, "description": "Proposed start (epoch seconds)" }, + "etime": { "type": "integer", "required": true, "description": "Proposed end (epoch seconds)" }, + "attendees": { "type": "array", "required": false, "description": "Invitee list [{uid,name}] to check" }, + "nid": { "type": "string", "required": false, "description": "Meeting being edited, excluded from its own check" } + }, + "returns": { + "type": "array", + "description": "Per-invitee availability", + "items": { + "type": "object", + "properties": { + "uid": { "type": "string" }, + "busy": { "type": "boolean" }, + "conflicts": { "type": "array", "description": "Overlapping meetings [{nid,title,stime,etime}]" } + } + } + }, + "errors": [] + }, + + "list": { + "doc": "List the current hub's scheduled meetings whose time window overlaps [stime, etime]. Reads schedule nodes (category=schedule) via room_list_scheduled, filtering on the canonical UNIX-epoch time persisted in node metadata. Both bounds optional; omit to return every scheduled meeting. Feeds the folder-window meeting calendar.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": { + "stime": { + "type": "integer", + "required": false, + "description": "Range start as UNIX-epoch seconds. Omit (with etime) for no time filter." + }, + "etime": { + "type": "integer", + "required": false, + "description": "Range end as UNIX-epoch seconds. Omit (with stime) for no time filter." + } + }, + "returns": { + "type": "array", + "description": "Scheduled meetings in range, ordered by start time", + "items": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Meeting node id (also room_id)" }, + "filename": { "type": "string", "description": "Meeting title" }, + "stime": { "type": "integer", "description": "Start time (epoch seconds)" }, + "etime": { "type": "integer", "description": "End time (epoch seconds)" }, + "metadata": { "type": "object", "description": "Full node metadata (content: attendees/title/message/date/stime/etime/room_id)" } + } + } + }, + "errors": [] + }, + + "remove": { + "doc": "Remove a meeting room and revoke all permissions. Revokes all permissions on the meeting node using permission_revoke with scope 'meeting' and returns the node ID as confirmation.", + "scope": "hub", + "permission": { + "src": "write", + "fast_check": "user_permission" + }, + "params": { + "nid": { + "type": "string", + "required": true, + "description": "Node ID of the meeting room to remove" + } + }, + "returns": { + "type": "object", + "description": "Confirmation of removal", + "properties": { + "nid": { + "type": "string", + "description": "Node ID of the removed meeting room" + } + } + }, + "errors": [] + }, + + "request_screen_access": { + "doc": "Request access to a peer screen sharing session. Reserved endpoint for future screen access request workflow. Currently not active in the service implementation.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": {}, + "returns": { + "type": "object", + "description": "Reserved" + }, + "errors": [] + }, + + "requestAccess": { + "doc": "Request access to join an active room as a participant. Verifies room permission via room_access, retrieves presenter and current peers, pushes user online status to 2 (in-meeting), and if the requester is the presenter sends a meeting.start signal to all peers.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "public-api" + }, + "params": { + "socket_id": { + "type": "string", + "required": true, + "description": "WebSocket socket ID of the requesting user" + }, + "room_id": { + "type": "string", + "required": true, + "description": "ID of the room to request access to" + }, + "hub_id": { + "type": "string", + "required": true, + "description": "Hub ID containing the room" + } + }, + "returns": { + "type": "object", + "description": "Room access context with peer and presenter information", + "properties": { + "presenter": { + "type": "object", + "description": "Current presenter data from room_get_presenter" + }, + "peers": { + "type": "array", + "description": "List of current room attendees excluding the requesting socket" + }, + "presenter_id": { + "type": "string", + "description": "User ID of the current presenter, or null if none" + }, + "room_id": { + "type": "string", + "description": "Room ID from the access record" + }, + "user": { + "type": "object", + "description": "Requesting user info (avatar_id, deviceId, role, ssid, uid, username)" + }, + "ssid": { + "type": "string", + "description": "Socket ID from the access record" + }, + "status": { + "type": "string", + "description": "Room access status" + }, + "peer": { + "type": "object", + "description": "Alias of user object for peer-to-peer signaling" + } + } + }, + "errors": [ + { + "code": "WEAK_PRIVILEGE OOOP", + "message": "User does not have sufficient permission to access the room", + "condition": "room_access returns no record or permission is falsy" + } + ] + }, + + "unified_room": { + "doc": "Manage unified room state for multi-participant sessions. Supports four operations via the flag parameter: get (retrieve room state), add (join with media settings using defaults), update (join with explicit required media settings), and remove (leave the unified room).", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "public-api" + }, + "params": { + "flag": { + "type": "string", + "required": true, + "description": "Operation to perform: 'get' to retrieve, 'add' to join with optional settings, 'update' to update with required settings, 'remove' to leave" + }, + "id": { + "type": "string", + "required": true, + "description": "Room ID to operate on" + }, + "uid": { + "type": "string", + "required": false, + "description": "User ID. Defaults to empty string if omitted." + }, + "is_mic_enabled": { + "type": "integer", + "required": false, + "description": "Microphone enabled state (0 or 1). Required when flag=update. Defaults to 1 when flag=add." + }, + "is_video_enabled": { + "type": "integer", + "required": false, + "description": "Camera enabled state (0 or 1). Required when flag=update. Defaults to 0 when flag=add." + }, + "is_share_enabled": { + "type": "integer", + "required": false, + "description": "Screen share enabled state (0 or 1). Required when flag=update. Defaults to 0 when flag=add." + }, + "is_write_enabled": { + "type": "integer", + "required": false, + "description": "Write/annotation enabled state (0 or 1). Required when flag=update. Defaults to 0 when flag=add." + }, + "metadata": { + "type": "object", + "required": false, + "description": "Additional room metadata object. Used when flag=add or flag=update. Defaults to empty object." + } + }, + "returns": { + "type": "array", + "description": "List of unified room records after the operation" + }, + "errors": [] + }, + + "update": { + "doc": "Update a scheduled meeting room. Supports partial or full updates controlled by the flag parameter. Flags: 'when' updates date only, 'title' updates title and renames the node, 'agenda' updates message, 'member' updates attendees and sends email invitations, 'all' updates all fields. Commits invitation emails to new attendees when flag includes member changes.", + "scope": "hub", + "permission": { + "src": "write", + "fast_check": "user_permission" + }, + "params": { + "flag": { + "type": "string", + "required": true, + "description": "Update scope: 'when' (date), 'title', 'agenda' (message), 'member' (attendees), or 'all' (all fields)" + }, + "nid": { + "type": "string", + "required": true, + "description": "Node ID of the meeting room to update" + }, + "date": { + "type": "string", + "required": false, + "description": "New meeting date/time string. Applied when flag is 'when' or 'all'." + }, + "stime": { + "type": "integer", + "required": false, + "description": "New meeting start time as UNIX-epoch seconds. Applied when flag is 'when' or 'all'; preserved if omitted." + }, + "etime": { + "type": "integer", + "required": false, + "description": "New meeting end time as UNIX-epoch seconds. Applied when flag is 'when' or 'all'; preserved if omitted." + }, + "title": { + "type": "string", + "required": false, + "description": "New meeting title. Applied when flag is 'title' or 'all'. Truncated to 100 characters." + }, + "message": { + "type": "string", + "required": false, + "description": "New meeting message/agenda. Applied when flag is 'agenda' or 'all'." + }, + "attendees": { + "type": "array", + "required": false, + "description": "Updated attendee list of WORKSPACE MEMBERS (applied when flag is 'member' or 'all'). Each entry is { uid, name } — no email/DMZ invite. Newly-added members get an in-app notification; visibility is workspace-wide." + }, + "recur": { + "type": "object", + "required": false, + "description": "Recurrence rule { freq: 'daily'|'weekly'|'monthly', until?: epoch-seconds } or null to clear. Flag-agnostic: applied whenever sent." + } + }, + "returns": { + "type": "object", + "description": "Updated meeting content fields", + "properties": { + "attendees": { + "type": "array", + "description": "Updated list of attendees" + }, + "title": { + "type": "string", + "description": "Updated meeting title" + }, + "message": { + "type": "string", + "description": "Updated meeting message/agenda" + }, + "date": { + "type": "string", + "description": "Updated meeting date/time" + } + } + }, + "errors": [] + }, + + "users": { + "doc": "List all users currently in a room. Reserved endpoint for room participant listing. Currently not active in the service implementation.", + "scope": "hub", + "permission": { + "src": "read", + "fast_check": "user_permission" + }, + "params": {}, + "returns": { + "type": "array", + "description": "Reserved" + }, + "errors": [] + } + }, + "modules": { + "public": "service/room", + "private": "service/private/room" + } } \ No newline at end of file From 76c8904be652344d7778e71e9afbc8a34d529c8c Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Fri, 2 Oct 2026 00:10:00 -0700 Subject: [PATCH 5/6] fix(room): link_files checks write access, caller's file access and meeting ownership Co-Authored-By: Claude Opus 5.5 (1M context) --- service/private/room.js | 11 ++++++++++- test/room-link-files.test.js | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/service/private/room.js b/service/private/room.js index 0d31f2e..3a7dbfe 100644 --- a/service/private/room.js +++ b/service/private/room.js @@ -372,13 +372,19 @@ class __private_room extends __public_room { * Params: nid (meeting), file_nids (array of media nids in this hub). */ async link_files() { + // Same gate as book(): the ACL's fast_check skips its declared `src`. + if (!(await memberCan(this, CAN_WRITE))) { + return this.exception.forbiden(); + } const nid = this.input.need(Attr.nid); const incoming = normalizeNids(this.input.need('file_nids')); const node = await this.db.await_proc('mfs_node_attr', nid); if (!isMeetingNode(node)) return this.exception.user("MEETING_NOT_FOUND"); const metadata = this.parseJSON(node.metadata || '{}') || {}; const content = this.parseJSON(metadata.content || '{}') || {}; - if (content.created_by && content.created_by !== this.uid) { + // Stricter than update(): attaching opens files to the link, so a legacy + // meeting with no recorded creator has nobody entitled to do it. + if (content.created_by !== this.uid) { return this.exception.user("NOT_MEETING_OWNER"); } const files = []; @@ -386,6 +392,9 @@ class __private_room extends __public_room { const a = await this.db.await_proc('mfs_node_attr', f); if (!a || !(a.id || a.nid)) continue; if (['folder', 'hub', 'root', 'schedule'].includes(a.filetype)) continue; + // The link will be able to download it, so the caller must be able to. + const mine = await this.db.await_proc('mfs_access_node', this.uid, f); + if (!mine || Number(mine.privilege) < Privilege.download) continue; files.push(f); } const { list, added, overflow } = mergeAttachments(attachmentsOf(content), files); diff --git a/test/room-link-files.test.js b/test/room-link-files.test.js index 31a8742..99809cf 100644 --- a/test/room-link-files.test.js +++ b/test/room-link-files.test.js @@ -22,12 +22,13 @@ require.cache[require.resolve("@drumee/server-essentials")] = { }, }; stub("service/room.js", class {}); -stub("service/lib/member-capability.js", { memberCan: async () => true, CAN_WRITE: 2 }); +let canWrite = true; +stub("service/lib/member-capability.js", { memberCan: async () => canWrite, CAN_WRITE: 2 }); const Room = require("../service/private/room"); const MEET = "aaaaaaaaaaaaaaaa", F1 = "1111111111111111", F2 = "2222222222222222", OWNER = "uuuuuuuuuuuuuuuu"; -function make({ input = {}, nodes = {}, publicPriv = 0, uid = OWNER } = {}) { +function make({ input = {}, nodes = {}, publicPriv = 0, callerPriv = 63, uid = OWNER } = {}) { const calls = []; const out = {}; const self = Object.create(Room.prototype); @@ -45,12 +46,12 @@ function make({ input = {}, nodes = {}, publicPriv = 0, uid = OWNER } = {}) { await_proc: async (name, ...args) => { calls.push([name, ...args]); if (name === "mfs_node_attr") return nodes[args[0]] || {}; - if (name === "mfs_access_node") return { privilege: publicPriv }; + if (name === "mfs_access_node") return { privilege: args[0] === PUBLIC ? publicPriv : callerPriv }; return {}; }, }, yp: { await_proc: async (name, ...args) => { calls.push([name, ...args]); return { token: "t" }; } }, - exception: { user: (code) => { out.error = code; } }, + exception: { user: (code) => { out.error = code; }, forbiden: () => { out.error = "FORBIDDEN"; } }, output: { data: (d) => { out.data = d; } }, randomString: () => "r", debug: () => {}, @@ -106,6 +107,31 @@ const fileNode = (id) => ({ id, filetype: "document", filename: "f" }); await c.self.link_files(); assert.deepStrictEqual(c.out.data.attachments, []); } + // Final review C1: a member who may not write is refused outright + { + canWrite = false; + const { self, calls, out } = make({ input: { nid: MEET, file_nids: [F1] }, + nodes: { [MEET]: meetingNode({ created_by: OWNER }), [F1]: fileNode(F1) } }); + await self.link_files(); + canWrite = true; + assert.strictEqual(out.error, "FORBIDDEN"); + assert.ok(!calls.some((c) => c[0] === "mfs_set_metadata")); + } + // Final review C1: a file the caller cannot download is never attached (nor granted) + { + const { self, calls, out } = make({ input: { nid: MEET, file_nids: [F1] }, callerPriv: 3, publicPriv: 15, + nodes: { [MEET]: meetingNode({ created_by: OWNER }), [F1]: fileNode(F1) } }); + await self.link_files(); + assert.deepStrictEqual(out.data.attachments, []); + assert.ok(!calls.some((c) => c[0] === "permission_grant")); + } + // Final review C1: a legacy meeting with no recorded creator has no owner to act for + { + const { self, out } = make({ input: { nid: MEET, file_nids: [F1] }, + nodes: { [MEET]: meetingNode({ title: "legacy" }), [F1]: fileNode(F1) } }); + await self.link_files(); + assert.strictEqual(out.error, "NOT_MEETING_OWNER"); + } // update keeps attachments (Review Focus 1) { const { self, calls, out } = make({ From dc7ee8372f546366678928b6a44ad8ac96517372 Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Fri, 2 Oct 2026 00:10:09 -0700 Subject: [PATCH 6/6] refactor(dmz): meeting_files relies on _shareByToken's password gate; test it for real Co-Authored-By: Claude Opus 5.5 (1M context) --- service/dmz.js | 10 +++------- test/dmz-meeting-files.test.js | 16 +++++++++++----- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/service/dmz.js b/service/dmz.js index 989949d..ae90111 100644 --- a/service/dmz.js +++ b/service/dmz.js @@ -1481,9 +1481,9 @@ class __dmz extends Mfs { * come from file/orig, which room.public_link / room.link_files opened to the * link identity. * - * A password-protected link answers nothing until the session has passed the - * password (dmz.login → session.dmz_login), or the token alone would leak - * the file names the password is meant to protect. + * A password-protected (or locked, or email-gated) link answers nothing: + * _shareByToken refuses gated shares outright, so the token alone never + * leaks the file names the password is meant to protect. * * Every nid comes through attachmentsOf, which only admits /^[0-9a-f]{16}$/, * so interpolating it into the forward_proc argument string is safe. @@ -1508,10 +1508,6 @@ class __dmz extends Mfs { return deny('TICKET_INVALID'); } if (!isMeetingNode(node)) return deny('NOT_A_MEETING'); - if (info.require_password) { - const access = (await this.db.await_proc('mfs_access_node', this.uid, nid)) || {}; - if (!access.privilege) return deny('REQUIRED_PASSWORD'); - } let meta = {}; try { meta = JSON.parse(node.metadata || '{}') || {}; diff --git a/test/dmz-meeting-files.test.js b/test/dmz-meeting-files.test.js index 6931a83..cff9688 100644 --- a/test/dmz-meeting-files.test.js +++ b/test/dmz-meeting-files.test.js @@ -20,14 +20,19 @@ const toArray = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]); const meeting_files = new Function("toArray", "attachmentsOf", "isMeetingNode", "Attr", `return ${slice("async meeting_files()")}`)(toArray, lib.attachmentsOf, lib.isMeetingNode, { token: "token" }); +// The real share resolver, so the password gate is the one production runs. +const shareByToken = new Function("toArray", `return ${slice("async _shareByToken(token, tag)")}`)(toArray); + const MEET = "aaaaaaaaaaaaaaaa", F1 = "1111111111111111"; -function make({ share, nodes = {}, viewerPriv = 0 }) { +function make({ share, legacy, nodes = {}, viewerPriv = 0 }) { const out = {}; const self = { uid: "vvvvvvvvvvvvvvvv", input: { need: () => "tok" }, - _shareByToken: async () => share, - yp: { await_proc: async (_p, _hub, proc, args) => { + _shareByToken: legacy ? shareByToken : async () => share, + yp: { await_proc: async (name, _hub, proc, args) => { + if (name === "secure_share_info") return []; + if (name === "dmz_info_next") return legacy; const id = String(args).replace(/'/g, ""); return proc === "mfs_node_attr" ? [nodes[id] || {}] : []; } }, @@ -60,8 +65,9 @@ const meetingNode = (attachments) => ({ id: MEET, filetype: "schedule", } // Review Focus 4: password-protected link without access lists nothing { - const t = make({ share: { info: { hub_id: "h", nid: MEET, require_password: 1 } }, - nodes: { [MEET]: meetingNode([F1]) }, viewerPriv: 0 }); + // viewerPriv 15: even a session holding the meeting grant gets nothing. + const t = make({ legacy: { hub_id: "h", nid: MEET, validity: "TICKET_OK", require_password: 1 }, + nodes: { [MEET]: meetingNode([F1]) }, viewerPriv: 15 }); await t.run(); assert.deepStrictEqual(t.out.data, { status: "REQUIRED_PASSWORD", items: [] }); }