@directus/api 33.0.0 → 33.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/ai/chat/controllers/chat.post.js +19 -4
  2. package/dist/ai/chat/lib/create-ui-stream.d.ts +7 -6
  3. package/dist/ai/chat/lib/create-ui-stream.js +28 -25
  4. package/dist/ai/chat/middleware/load-settings.js +31 -7
  5. package/dist/ai/chat/models/chat-request.d.ts +135 -2
  6. package/dist/ai/chat/models/chat-request.js +56 -2
  7. package/dist/ai/chat/models/providers.d.ts +16 -2
  8. package/dist/ai/chat/models/providers.js +16 -2
  9. package/dist/ai/chat/utils/chat-request-tool-to-ai-sdk-tool.js +3 -4
  10. package/dist/ai/chat/utils/format-context.d.ts +5 -0
  11. package/dist/ai/chat/utils/format-context.js +122 -0
  12. package/dist/ai/mcp/server.d.ts +27 -1
  13. package/dist/ai/providers/index.d.ts +3 -0
  14. package/dist/ai/providers/index.js +3 -0
  15. package/dist/ai/providers/options.d.ts +14 -0
  16. package/dist/ai/providers/options.js +26 -0
  17. package/dist/ai/providers/registry.d.ts +6 -0
  18. package/dist/ai/providers/registry.js +65 -0
  19. package/dist/ai/providers/types.d.ts +34 -0
  20. package/dist/ai/providers/types.js +1 -0
  21. package/dist/ai/tools/items/index.js +4 -1
  22. package/dist/ai/tools/items/prompt.md +7 -9
  23. package/dist/ai/tools/schema.js +1 -1
  24. package/dist/app.js +4 -0
  25. package/dist/auth/drivers/ldap.d.ts +1 -1
  26. package/dist/auth/drivers/ldap.js +142 -137
  27. package/dist/cache.d.ts +12 -0
  28. package/dist/cache.js +25 -1
  29. package/dist/cli/utils/create-env/env-stub.liquid +3 -0
  30. package/dist/controllers/deployment.d.ts +2 -0
  31. package/dist/controllers/deployment.js +481 -0
  32. package/dist/controllers/fields.js +6 -4
  33. package/dist/database/get-ast-from-query/lib/parse-fields.js +2 -2
  34. package/dist/database/migrations/20260110A-add-ai-provider-settings.d.ts +3 -0
  35. package/dist/database/migrations/20260110A-add-ai-provider-settings.js +35 -0
  36. package/dist/database/migrations/20260128A-add-collaborative-editing.d.ts +3 -0
  37. package/dist/database/migrations/20260128A-add-collaborative-editing.js +10 -0
  38. package/dist/database/migrations/20260204A-add-deployment.d.ts +3 -0
  39. package/dist/database/migrations/20260204A-add-deployment.js +32 -0
  40. package/dist/database/run-ast/lib/apply-query/add-join.js +1 -1
  41. package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
  42. package/dist/database/run-ast/lib/apply-query/filter/index.js +1 -1
  43. package/dist/database/run-ast/lib/apply-query/sort.js +1 -1
  44. package/dist/deployment/deployment.d.ts +94 -0
  45. package/dist/deployment/deployment.js +29 -0
  46. package/dist/deployment/drivers/index.d.ts +1 -0
  47. package/dist/deployment/drivers/index.js +1 -0
  48. package/dist/deployment/drivers/vercel.d.ts +32 -0
  49. package/dist/deployment/drivers/vercel.js +208 -0
  50. package/dist/deployment/index.d.ts +2 -0
  51. package/dist/deployment/index.js +2 -0
  52. package/dist/deployment.d.ts +24 -0
  53. package/dist/deployment.js +39 -0
  54. package/dist/middleware/respond.js +27 -14
  55. package/dist/permissions/modules/process-ast/utils/find-related-collection.js +1 -1
  56. package/dist/permissions/modules/validate-access/lib/validate-item-access.d.ts +1 -1
  57. package/dist/permissions/modules/validate-access/lib/validate-item-access.js +19 -8
  58. package/dist/server.js +2 -1
  59. package/dist/services/deployment-projects.d.ts +20 -0
  60. package/dist/services/deployment-projects.js +34 -0
  61. package/dist/services/deployment-runs.d.ts +13 -0
  62. package/dist/services/deployment-runs.js +6 -0
  63. package/dist/services/deployment.d.ts +40 -0
  64. package/dist/services/deployment.js +202 -0
  65. package/dist/services/graphql/resolvers/system-admin.js +2 -3
  66. package/dist/services/graphql/utils/filter-replace-m2a.js +3 -4
  67. package/dist/services/index.d.ts +3 -0
  68. package/dist/services/index.js +3 -0
  69. package/dist/services/server.js +1 -0
  70. package/dist/services/specifications.js +2 -2
  71. package/dist/services/versions.js +1 -1
  72. package/dist/telemetry/lib/get-report.js +2 -0
  73. package/dist/telemetry/types/report.d.ts +8 -0
  74. package/dist/telemetry/utils/get-settings.d.ts +2 -0
  75. package/dist/telemetry/utils/get-settings.js +5 -0
  76. package/dist/utils/deep-map-response.d.ts +1 -1
  77. package/dist/utils/deep-map-response.js +1 -1
  78. package/dist/utils/get-column-path.js +1 -1
  79. package/dist/utils/get-service.js +7 -1
  80. package/dist/utils/is-field-allowed.d.ts +4 -0
  81. package/dist/utils/is-field-allowed.js +9 -0
  82. package/dist/utils/versioning/handle-version.js +1 -1
  83. package/dist/websocket/collab/calculate-cache-metadata.d.ts +9 -0
  84. package/dist/websocket/collab/calculate-cache-metadata.js +121 -0
  85. package/dist/websocket/collab/collab.d.ts +63 -0
  86. package/dist/websocket/collab/collab.js +481 -0
  87. package/dist/websocket/collab/constants.d.ts +1 -0
  88. package/dist/websocket/collab/constants.js +13 -0
  89. package/dist/websocket/collab/filter-to-fields.d.ts +2 -0
  90. package/dist/websocket/collab/filter-to-fields.js +11 -0
  91. package/dist/websocket/collab/messenger.d.ts +43 -0
  92. package/dist/websocket/collab/messenger.js +225 -0
  93. package/dist/websocket/collab/payload-permissions.d.ts +18 -0
  94. package/dist/websocket/collab/payload-permissions.js +158 -0
  95. package/dist/websocket/collab/permissions-cache.d.ts +52 -0
  96. package/dist/websocket/collab/permissions-cache.js +204 -0
  97. package/dist/websocket/collab/room.d.ts +125 -0
  98. package/dist/websocket/collab/room.js +593 -0
  99. package/dist/websocket/collab/store.d.ts +7 -0
  100. package/dist/websocket/collab/store.js +33 -0
  101. package/dist/websocket/collab/types.d.ts +21 -0
  102. package/dist/websocket/collab/types.js +1 -0
  103. package/dist/websocket/collab/verify-permissions.d.ts +11 -0
  104. package/dist/websocket/collab/verify-permissions.js +100 -0
  105. package/dist/websocket/handlers/index.d.ts +2 -0
  106. package/dist/websocket/handlers/index.js +9 -0
  107. package/dist/websocket/utils/items.d.ts +2 -2
  108. package/dist/websocket/utils/message.d.ts +1 -1
  109. package/dist/websocket/utils/message.js +2 -2
  110. package/package.json +32 -30
  111. package/dist/utils/get-relation-info.d.ts +0 -6
  112. package/dist/utils/get-relation-info.js +0 -43
  113. package/dist/utils/get-relation-type.d.ts +0 -6
  114. package/dist/utils/get-relation-type.js +0 -18
  115. package/dist/utils/versioning/deep-map-with-schema.d.ts +0 -23
  116. package/dist/utils/versioning/deep-map-with-schema.js +0 -81
@@ -0,0 +1,63 @@
1
+ import { type WebSocketClient } from '@directus/types';
2
+ import { Messenger } from './messenger.js';
3
+ import { RoomManager } from './room.js';
4
+ import type { DiscardMessage, FocusMessage, JoinMessage, LeaveMessage, UpdateAllMessage, UpdateMessage } from './types.js';
5
+ /**
6
+ * Handler responsible for subscriptions
7
+ */
8
+ export declare class CollabHandler {
9
+ roomManager: RoomManager;
10
+ messenger: Messenger;
11
+ enabled: boolean;
12
+ private initialized;
13
+ private initializePromise?;
14
+ private settingsService?;
15
+ private cleanupJob?;
16
+ private cleanupInterval?;
17
+ private busHandler?;
18
+ private eventQueue;
19
+ /**
20
+ * Initialize the handler
21
+ */
22
+ constructor();
23
+ initialize(force?: boolean): Promise<void>;
24
+ bindWebSocket(): void;
25
+ startBackgroundJobs(): void;
26
+ /**
27
+ * Terminate the handler and stop background jobs
28
+ */
29
+ terminate(): Promise<void>;
30
+ /**
31
+ * Ensure collaborative editing is enabled and initialized
32
+ */
33
+ ensureEnabled(): Promise<void>;
34
+ /**
35
+ * Join a collaborative editing room
36
+ */
37
+ onJoin(client: WebSocketClient, message: JoinMessage): Promise<void>;
38
+ /**
39
+ * Leave a collaborative editing room
40
+ */
41
+ onLeave(client: WebSocketClient, message?: LeaveMessage): Promise<void>;
42
+ /**
43
+ * Update a field value
44
+ */
45
+ onUpdate(client: WebSocketClient, message: UpdateMessage): Promise<void>;
46
+ /**
47
+ * Update multiple field values
48
+ */
49
+ onUpdateAll(client: WebSocketClient, message: UpdateAllMessage): Promise<void>;
50
+ /**
51
+ * Update focus state
52
+ */
53
+ onFocus(client: WebSocketClient, message: FocusMessage): Promise<void>;
54
+ /**
55
+ * Discard specified changes in the room
56
+ */
57
+ onDiscard(client: WebSocketClient, message: DiscardMessage): Promise<void>;
58
+ /**
59
+ * Verify field access for both READ and UPDATE permissions
60
+ */
61
+ private checkFieldsAccess;
62
+ private getAllowedFields;
63
+ }
@@ -0,0 +1,481 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { ForbiddenError, InvalidPayloadError, ServiceUnavailableError } from '@directus/errors';
3
+ import { WS_TYPE } from '@directus/types';
4
+ import { ClientMessage } from '@directus/types/collab';
5
+ import { toArray } from '@directus/utils';
6
+ import { difference, intersection, isEmpty, upperFirst } from 'lodash-es';
7
+ import getDatabase from '../../database/index.js';
8
+ import emitter from '../../emitter.js';
9
+ import { useLogger } from '../../logger/index.js';
10
+ import { validateItemAccess } from '../../permissions/modules/validate-access/lib/validate-item-access.js';
11
+ import { SettingsService } from '../../services/settings.js';
12
+ import { getSchema } from '../../utils/get-schema.js';
13
+ import { isFieldAllowed } from '../../utils/is-field-allowed.js';
14
+ import { scheduleSynchronizedJob } from '../../utils/schedule.js';
15
+ import { getMessageType } from '../utils/message.js';
16
+ import { IRRELEVANT_COLLECTIONS } from './constants.js';
17
+ import { Messenger } from './messenger.js';
18
+ import { validateChanges } from './payload-permissions.js';
19
+ import { RoomManager } from './room.js';
20
+ import { verifyPermissions } from './verify-permissions.js';
21
+ const env = useEnv();
22
+ const CLUSTER_CLEANUP_CRON = String(env['WEBSOCKETS_COLLAB_CLUSTER_CLEANUP_CRON']);
23
+ const LOCAL_CLEANUP_INTERVAL = Number(env['WEBSOCKETS_COLLAB_LOCAL_CLEANUP_INTERVAL']);
24
+ /**
25
+ * Handler responsible for subscriptions
26
+ */
27
+ export class CollabHandler {
28
+ roomManager;
29
+ messenger = new Messenger();
30
+ enabled = false;
31
+ initialized;
32
+ initializePromise;
33
+ settingsService;
34
+ cleanupJob;
35
+ cleanupInterval;
36
+ busHandler;
37
+ eventQueue = Promise.resolve();
38
+ /**
39
+ * Initialize the handler
40
+ */
41
+ constructor() {
42
+ this.roomManager = new RoomManager(this.messenger);
43
+ this.initialized = this.initialize();
44
+ this.bindWebSocket();
45
+ this.startBackgroundJobs();
46
+ }
47
+ initialize(force = false) {
48
+ if (this.initialized && !force)
49
+ return this.initialized;
50
+ if (this.initializePromise)
51
+ return this.initializePromise;
52
+ this.initializePromise = (async () => {
53
+ try {
54
+ if (!this.settingsService) {
55
+ const schema = await getSchema();
56
+ this.settingsService = new SettingsService({ schema });
57
+ }
58
+ const settings = await this.settingsService.readSingleton({ fields: ['collaborative_editing_enabled'] });
59
+ this.enabled = settings?.['collaborative_editing_enabled'] ?? false;
60
+ }
61
+ catch (err) {
62
+ useLogger().error(err, '[Collab] Failed to initialize collaborative editing settings');
63
+ }
64
+ finally {
65
+ this.initializePromise = undefined;
66
+ }
67
+ })();
68
+ if (!this.initialized) {
69
+ this.initialized = this.initializePromise;
70
+ }
71
+ return this.initializePromise;
72
+ }
73
+ bindWebSocket() {
74
+ /**
75
+ * Listen for all system events via bus to ensure once-only delivery and consistency across instances
76
+ *
77
+ * Local updates:
78
+ * Service -> Emitter -> Hooks -> Bus -> CollabHandler -> Room -> Local Clients
79
+ *
80
+ * Remote updates:
81
+ * Service (Node B) -> Emitter (Node B) -> Hooks (Node B) -> Bus -> CollabHandler (Node A) -> Room (Node A) -> Remote Clients
82
+ */
83
+ this.busHandler = (event) => {
84
+ // Chain events to enforce sequence integrity
85
+ this.eventQueue = this.eventQueue
86
+ .then(async () => {
87
+ if (event.collection === 'directus_settings' &&
88
+ event.action === 'update' &&
89
+ 'collaborative_editing_enabled' in event.payload) {
90
+ useLogger().debug(`[Collab] [Node ${this.messenger.uid}] Settings update via bus, triggering handler`);
91
+ // Non-blocking initialization to avoid resource contention
92
+ this.initialize(true)
93
+ .then(() => {
94
+ if (!this.enabled) {
95
+ try {
96
+ useLogger().debug(`[Collab] [Node ${this.messenger.uid}] Collaborative editing disabled, terminating all rooms`);
97
+ this.roomManager.terminateAll();
98
+ }
99
+ catch (err) {
100
+ useLogger().error(err, '[Collab] Collaborative editing disabling terminateAll failed');
101
+ }
102
+ }
103
+ })
104
+ .catch((err) => {
105
+ useLogger().error(err, '[Collab] Collaborative editing re-initialization failed');
106
+ });
107
+ return;
108
+ }
109
+ // Skip irrelevant collections and actions early
110
+ if (event.action === 'create' || IRRELEVANT_COLLECTIONS.includes(event.collection)) {
111
+ return;
112
+ }
113
+ if (event.action === 'update' || event.action === 'delete') {
114
+ let keys = [];
115
+ if (Array.isArray(event.keys)) {
116
+ keys = event.keys;
117
+ }
118
+ else if (event.key) {
119
+ keys = [event.key];
120
+ }
121
+ else if (event.payload && event.action === 'delete') {
122
+ keys = toArray(event.payload);
123
+ }
124
+ event.keys = keys;
125
+ const roomsToUpdate = Object.values(this.roomManager.rooms).filter((room) => {
126
+ // Versioned Rooms
127
+ if (room.version) {
128
+ return event.collection === 'directus_versions' && keys.some((key) => String(key) === room.version);
129
+ }
130
+ // Skip non-matching collections and version events
131
+ if (room.collection !== event.collection || event.collection === 'directus_versions')
132
+ return false;
133
+ // Match singleton
134
+ if (room.item === null)
135
+ return true;
136
+ // Match regular items
137
+ return keys.some((key) => String(key) === String(room.item));
138
+ });
139
+ if (roomsToUpdate.length === 0)
140
+ return;
141
+ await Promise.all(roomsToUpdate.map(async (room) => {
142
+ let relevantKeys;
143
+ if (room.version) {
144
+ relevantKeys = [room.version];
145
+ }
146
+ else if (room.item) {
147
+ relevantKeys = [room.item];
148
+ }
149
+ else {
150
+ relevantKeys = keys;
151
+ }
152
+ const singleKeyedEvent = { ...event, keys: relevantKeys };
153
+ if (event.action === 'delete') {
154
+ await room.onDeleteHandler(singleKeyedEvent);
155
+ }
156
+ else {
157
+ await room.onUpdateHandler(singleKeyedEvent);
158
+ }
159
+ }));
160
+ }
161
+ })
162
+ .catch((err) => {
163
+ useLogger().error(err, `[Collab] Bus message processing failed for ${event.collection}/${event.action}`);
164
+ });
165
+ };
166
+ this.messenger.messenger.subscribe('websocket.event', this.busHandler);
167
+ emitter.onAction('websocket.connect', ({ client }) => {
168
+ this.messenger.addClient(client);
169
+ });
170
+ // listen to incoming messages on the connected websockets
171
+ emitter.onAction('websocket.message', async ({ client, message }) => {
172
+ if (getMessageType(message) !== WS_TYPE.COLLAB)
173
+ return;
174
+ try {
175
+ await this.ensureEnabled();
176
+ }
177
+ catch (error) {
178
+ if (error instanceof ServiceUnavailableError && error.message.includes('Collaborative editing is disabled')) {
179
+ this.messenger.handleError(client.uid, error, message.action);
180
+ this.messenger.terminateClient(client.uid);
181
+ return;
182
+ }
183
+ throw error;
184
+ }
185
+ const { data, error } = ClientMessage.safeParse(message);
186
+ if (!data) {
187
+ this.messenger.handleError(client.uid, new InvalidPayloadError({
188
+ reason: `Couldn't parse payload. ${error.message}`,
189
+ }));
190
+ return;
191
+ }
192
+ try {
193
+ await this[`on${upperFirst(data.action)}`](client, message);
194
+ }
195
+ catch (error) {
196
+ this.messenger.handleError(client.uid, error, data?.action);
197
+ }
198
+ });
199
+ // unsubscribe when a connection drops
200
+ emitter.onAction('websocket.error', ({ client }) => this.onLeave(client));
201
+ emitter.onAction('websocket.close', ({ client }) => this.onLeave(client));
202
+ }
203
+ startBackgroundJobs() {
204
+ this.cleanupJob = scheduleSynchronizedJob('collab', CLUSTER_CLEANUP_CRON, async () => {
205
+ const { inactive } = await this.messenger.pruneDeadInstances();
206
+ // Remove clients and close rooms hosted by nodes that are now dead
207
+ for (const roomUid of inactive.rooms) {
208
+ const room = await this.roomManager.getRoom(roomUid);
209
+ if (room) {
210
+ // Remove dead clients globally
211
+ for (const client of inactive.clients) {
212
+ if (await room.hasClient(client)) {
213
+ useLogger().debug(`[Collab] Removing dead client ${client} from room ${roomUid}`);
214
+ await room.leave(client);
215
+ }
216
+ }
217
+ // Close room if it was truly abandoned
218
+ if (await room.close()) {
219
+ this.roomManager.removeRoom(room.uid);
220
+ }
221
+ }
222
+ }
223
+ });
224
+ this.cleanupInterval = setInterval(async () => {
225
+ try {
226
+ // Remove local clients that are no longer in the global registry
227
+ const globalClients = await this.messenger.getGlobalClients();
228
+ const localClients = (await this.roomManager.getLocalRoomClients()).map((client) => client.uid);
229
+ const invalidClients = difference(localClients, globalClients);
230
+ for (const client of invalidClients) {
231
+ const rooms = await this.roomManager.getClientRooms(client);
232
+ for (const room of rooms) {
233
+ useLogger().debug(`[Collab] Removing invalid client ${client} from room ${room.getDisplayName()}`);
234
+ await room.leave(client);
235
+ }
236
+ }
237
+ await this.roomManager.cleanupRooms();
238
+ }
239
+ catch (err) {
240
+ useLogger().error(err, '[Collab] Local cleanup interval failed');
241
+ }
242
+ }, LOCAL_CLEANUP_INTERVAL);
243
+ }
244
+ /**
245
+ * Terminate the handler and stop background jobs
246
+ */
247
+ async terminate() {
248
+ await this.cleanupJob?.stop();
249
+ if (this.cleanupInterval) {
250
+ clearInterval(this.cleanupInterval);
251
+ }
252
+ if (this.busHandler) {
253
+ await this.messenger.messenger.unsubscribe('websocket.event', this.busHandler);
254
+ }
255
+ }
256
+ /**
257
+ * Ensure collaborative editing is enabled and initialized
258
+ */
259
+ async ensureEnabled() {
260
+ await this.initialized;
261
+ if (!this.enabled) {
262
+ throw new ServiceUnavailableError({
263
+ reason: 'Collaborative editing is disabled',
264
+ service: 'collab',
265
+ });
266
+ }
267
+ }
268
+ /**
269
+ * Join a collaborative editing room
270
+ */
271
+ async onJoin(client, message) {
272
+ if (client.accountability?.share) {
273
+ throw new ForbiddenError({
274
+ reason: 'Collaborative editing is not supported for shares',
275
+ });
276
+ }
277
+ const schema = await getSchema();
278
+ const db = getDatabase();
279
+ try {
280
+ const { accessAllowed } = await validateItemAccess({
281
+ accountability: client.accountability,
282
+ action: 'read',
283
+ collection: message.collection,
284
+ primaryKeys: schema.collections[message.collection]?.singleton ? [] : [message.item],
285
+ }, { knex: db, schema });
286
+ if (!accessAllowed)
287
+ throw new ForbiddenError();
288
+ if (message.version) {
289
+ const { accessAllowed: versionAccessAllowed } = await validateItemAccess({
290
+ accountability: client.accountability,
291
+ action: 'read',
292
+ collection: 'directus_versions',
293
+ primaryKeys: [message.version],
294
+ }, { knex: db, schema });
295
+ if (!versionAccessAllowed)
296
+ throw new ForbiddenError();
297
+ }
298
+ }
299
+ catch {
300
+ throw new ForbiddenError({
301
+ reason: `No permission to access item or it does not exist`,
302
+ });
303
+ }
304
+ if (message.initialChanges) {
305
+ await validateChanges(message.initialChanges, message.collection, message.item, {
306
+ knex: db,
307
+ schema,
308
+ accountability: client.accountability,
309
+ });
310
+ }
311
+ const room = await this.roomManager.createRoom(message.collection, message.item, message.version ?? null, message.initialChanges);
312
+ await room.join(client, message.color);
313
+ }
314
+ /**
315
+ * Leave a collaborative editing room
316
+ */
317
+ async onLeave(client, message) {
318
+ if (message?.room) {
319
+ const room = await this.roomManager.getRoom(message.room);
320
+ if (!room || !(await room.hasClient(client.uid))) {
321
+ throw new ForbiddenError({
322
+ reason: `No access to room "${message.room}" or it does not exist`,
323
+ });
324
+ }
325
+ await room.leave(client.uid);
326
+ }
327
+ else {
328
+ const rooms = await this.roomManager.getClientRooms(client.uid);
329
+ for (const room of rooms) {
330
+ await room.leave(client.uid);
331
+ }
332
+ }
333
+ }
334
+ /**
335
+ * Update a field value
336
+ */
337
+ async onUpdate(client, message) {
338
+ const knex = getDatabase();
339
+ const schema = await getSchema();
340
+ const room = await this.roomManager.getRoom(message.room);
341
+ if (!room || !(await room.hasClient(client.uid))) {
342
+ throw new ForbiddenError({
343
+ reason: `No access to room ${message.room} or room does not exist`,
344
+ });
345
+ }
346
+ await this.checkFieldsAccess(client, room, message.field, 'update', { knex, schema });
347
+ // Focus field before update to prevent concurrent overwrite conflicts
348
+ let focus = await room.getFocusByUser(client.uid);
349
+ if (message.changes !== undefined) {
350
+ if (focus !== message.field) {
351
+ await room.focus(client, message.field);
352
+ focus = await room.getFocusByUser(client.uid);
353
+ }
354
+ // Focus field before update to prevent concurrent overwrite conflicts
355
+ if (!focus || focus !== message.field) {
356
+ throw new ForbiddenError({
357
+ reason: `Cannot update field ${message.field} without focusing on it first`,
358
+ });
359
+ }
360
+ await validateChanges({ [message.field]: message.changes }, room.collection, room.item, {
361
+ knex,
362
+ schema,
363
+ accountability: client.accountability,
364
+ });
365
+ await room.update(client, { [message.field]: message.changes });
366
+ }
367
+ else {
368
+ const currentFocuser = await room.getFocusByField(message.field);
369
+ if (currentFocuser && currentFocuser !== client.uid) {
370
+ throw new ForbiddenError({
371
+ reason: `Field ${message.field} is already focused by another user`,
372
+ });
373
+ }
374
+ await room.unset(client, message.field);
375
+ }
376
+ }
377
+ /**
378
+ * Update multiple field values
379
+ */
380
+ async onUpdateAll(client, message) {
381
+ if (isEmpty(message.changes))
382
+ return;
383
+ const room = await this.roomManager.getRoom(message.room);
384
+ if (!room || !(await room.hasClient(client.uid)))
385
+ throw new ForbiddenError({
386
+ reason: `No access to room ${message.room} or room does not exist`,
387
+ });
388
+ const collection = room.collection;
389
+ const knex = getDatabase();
390
+ const schema = await getSchema();
391
+ const fields = Object.keys(message.changes ?? {});
392
+ for (const key of fields) {
393
+ const focus = await room.getFocusByField(key);
394
+ if (focus && focus !== client.uid) {
395
+ delete message.changes?.[key];
396
+ }
397
+ }
398
+ if (!isEmpty(message.changes)) {
399
+ await validateChanges(message.changes, collection, room.item, {
400
+ knex,
401
+ schema,
402
+ accountability: client.accountability,
403
+ });
404
+ await room.update(client, message.changes);
405
+ }
406
+ }
407
+ /**
408
+ * Update focus state
409
+ */
410
+ async onFocus(client, message) {
411
+ const room = await this.roomManager.getRoom(message.room);
412
+ if (!room || !(await room.hasClient(client.uid)))
413
+ throw new ForbiddenError({
414
+ reason: `No access to room ${message.room} or room does not exist`,
415
+ });
416
+ if (message.field) {
417
+ await this.checkFieldsAccess(client, room, message.field, 'focus on');
418
+ }
419
+ if (!(await room.focus(client, message.field ?? null))) {
420
+ throw new ForbiddenError({
421
+ reason: `Field ${message.field} is already focused by another user`,
422
+ });
423
+ }
424
+ }
425
+ /**
426
+ * Discard specified changes in the room
427
+ */
428
+ async onDiscard(client, message) {
429
+ const room = await this.roomManager.getRoom(message.room);
430
+ if (!room || !(await room.hasClient(client.uid))) {
431
+ throw new ForbiddenError({
432
+ reason: `No access to room ${message.room} or room does not exist`,
433
+ });
434
+ }
435
+ const knex = getDatabase();
436
+ const schema = await getSchema();
437
+ const allowedFields = await this.getAllowedFields(client, room, knex, schema);
438
+ if (!allowedFields || allowedFields.length === 0) {
439
+ throw new ForbiddenError({
440
+ reason: `No permission to discard fields or item does not exist`,
441
+ });
442
+ }
443
+ await room.discard(allowedFields);
444
+ }
445
+ /**
446
+ * Verify field access for both READ and UPDATE permissions
447
+ */
448
+ async checkFieldsAccess(client, room, fields, errorAction, options = {}) {
449
+ const knex = options.knex ?? getDatabase();
450
+ const schema = options.schema ?? (await getSchema());
451
+ const allowedFields = await this.getAllowedFields(client, room, knex, schema);
452
+ const fieldsArray = Array.isArray(fields) ? fields : [fields];
453
+ for (const field of fieldsArray) {
454
+ const fieldExists = !!schema.collections[room.collection]?.fields[field];
455
+ if (!fieldExists || (allowedFields && !isFieldAllowed(allowedFields, field))) {
456
+ throw new ForbiddenError({
457
+ reason: `No permission to ${errorAction} field ${field} or field does not exist`,
458
+ });
459
+ }
460
+ }
461
+ }
462
+ async getAllowedFields(client, room, knex, schema) {
463
+ const [read, update] = await Promise.all([
464
+ verifyPermissions(client.accountability, room.collection, room.item, 'read', { knex, schema }),
465
+ verifyPermissions(client.accountability, room.collection, room.item, 'update', { knex, schema }),
466
+ ]);
467
+ if (read === null && update === null)
468
+ return null;
469
+ if (read === null)
470
+ return update;
471
+ if (update === null)
472
+ return read;
473
+ if (read.includes('*') && update.includes('*'))
474
+ return ['*'];
475
+ if (read.includes('*'))
476
+ return update;
477
+ if (update.includes('*'))
478
+ return read;
479
+ return intersection(read, update);
480
+ }
481
+ }
@@ -0,0 +1 @@
1
+ export declare const IRRELEVANT_COLLECTIONS: string[];
@@ -0,0 +1,13 @@
1
+ export const IRRELEVANT_COLLECTIONS = [
2
+ 'directus_activity',
3
+ 'directus_extensions',
4
+ 'directus_flows',
5
+ 'directus_folders',
6
+ 'directus_migrations',
7
+ 'directus_notifications',
8
+ 'directus_operations',
9
+ 'directus_presets',
10
+ 'directus_revisions',
11
+ 'directus_sessions',
12
+ 'directus_shares',
13
+ ];
@@ -0,0 +1,2 @@
1
+ import type { Filter, SchemaOverview } from '@directus/types';
2
+ export declare function filterToFields(filter: Filter, collection: string, schema: SchemaOverview): string[];
@@ -0,0 +1,11 @@
1
+ import { deepMapFilter } from '@directus/utils';
2
+ export function filterToFields(filter, collection, schema) {
3
+ const fields = new Set();
4
+ deepMapFilter(filter, ([key, _value], context) => {
5
+ if (context.leaf && context.field) {
6
+ fields.add([...context.path, key].join('.'));
7
+ }
8
+ return undefined;
9
+ }, { collection, schema });
10
+ return Array.from(fields);
11
+ }
@@ -0,0 +1,43 @@
1
+ import type { Bus } from '@directus/memory';
2
+ import { type WebSocketClient } from '@directus/types';
3
+ import { type BroadcastMessage, type ClientID, type ServerError, type ServerMessage } from '@directus/types/collab';
4
+ type Instance = {
5
+ clients: ClientID[];
6
+ rooms: string[];
7
+ };
8
+ type Registry = Record<string, Instance>;
9
+ type RegistrySnapshot = {
10
+ inactive: Instance;
11
+ active: ClientID[];
12
+ };
13
+ type RoomMessage = Extract<BroadcastMessage, {
14
+ type: 'room';
15
+ }>;
16
+ export type RoomListener = (message: RoomMessage) => void;
17
+ export declare class Messenger {
18
+ uid: `${string}-${string}-${string}-${string}-${string}`;
19
+ store: <T>(callback: (store: import("./store.js").RedisStore<{
20
+ instances: Registry;
21
+ }>) => Promise<T>) => Promise<T>;
22
+ clients: Record<ClientID, WebSocketClient>;
23
+ orders: Record<ClientID, number>;
24
+ messenger: Bus;
25
+ roomListeners: Record<string, RoomListener>;
26
+ constructor();
27
+ hasClient(client: ClientID): boolean;
28
+ setRoomListener(room: string, callback: RoomListener): void;
29
+ removeRoomListener(room: string): void;
30
+ addClient(client: WebSocketClient): void;
31
+ removeClient(uid: ClientID): void;
32
+ registerRoom(uid: string): Promise<void>;
33
+ unregisterRoom(uid: string): Promise<void>;
34
+ getLocalClients(): Promise<ClientID[]>;
35
+ getGlobalClients(): Promise<ClientID[]>;
36
+ pruneDeadInstances(): Promise<RegistrySnapshot>;
37
+ sendRoom(room: string, message: Omit<RoomMessage, 'type' | 'room'>): void;
38
+ sendClient(client: ClientID, message: Omit<ServerMessage, 'order'>): void;
39
+ terminateClient(client: ClientID): void;
40
+ sendError(client: ClientID, error: ServerError): void;
41
+ handleError(client: ClientID, error: unknown, action?: ServerError['trigger']): void;
42
+ }
43
+ export {};