@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,225 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { useEnv } from '@directus/env';
3
+ import { isDirectusError } from '@directus/errors';
4
+ import { WS_TYPE } from '@directus/types';
5
+ import { COLLAB_BUS, } from '@directus/types/collab';
6
+ import { useBus } from '../../bus/index.js';
7
+ import { useLogger } from '../../logger/index.js';
8
+ import { useStore } from './store.js';
9
+ const env = useEnv();
10
+ const INSTANCE_TIMEOUT = Number(env['WEBSOCKETS_COLLAB_INSTANCE_TIMEOUT']);
11
+ export class Messenger {
12
+ uid;
13
+ store;
14
+ clients = {};
15
+ orders = {};
16
+ messenger = useBus();
17
+ roomListeners = {};
18
+ constructor() {
19
+ this.uid = randomUUID();
20
+ this.store = useStore('registry', { instances: {} });
21
+ this.store(async (store) => {
22
+ const instances = await store.get('instances');
23
+ instances[this.uid] = { clients: [], rooms: [] };
24
+ await store.set('instances', instances);
25
+ }).catch((err) => {
26
+ useLogger().error(err, '[Collab] Failed to register instance in registry');
27
+ });
28
+ this.messenger.subscribe(COLLAB_BUS, (message) => {
29
+ if (message.type === 'send') {
30
+ const client = this.clients[message.client];
31
+ if (client) {
32
+ const order = this.orders[client.uid] ?? 0;
33
+ this.orders[client.uid] = order + 1;
34
+ client.send(JSON.stringify({ ...message.message, order }));
35
+ }
36
+ }
37
+ else if (message.type === 'error') {
38
+ const client = this.clients[message.client];
39
+ if (client) {
40
+ client.send(JSON.stringify(message.message));
41
+ }
42
+ }
43
+ else if (message.type === 'terminate') {
44
+ this.clients[message.client]?.close();
45
+ }
46
+ else if (message.type === 'room') {
47
+ this.roomListeners[message.room]?.(message);
48
+ }
49
+ else if (message.type === 'ping' && message.instance === this.uid) {
50
+ this.messenger.publish(COLLAB_BUS, { type: 'pong', instance: this.uid });
51
+ }
52
+ });
53
+ }
54
+ hasClient(client) {
55
+ return client in this.clients;
56
+ }
57
+ setRoomListener(room, callback) {
58
+ this.roomListeners[room] = callback;
59
+ }
60
+ removeRoomListener(room) {
61
+ delete this.roomListeners[room];
62
+ }
63
+ addClient(client) {
64
+ if (client.uid in this.clients)
65
+ return;
66
+ this.clients[client.uid] = client;
67
+ this.orders[client.uid] = 0;
68
+ this.store(async (store) => {
69
+ const instances = await store.get('instances');
70
+ if (!instances[this.uid])
71
+ instances[this.uid] = { clients: [], rooms: [] };
72
+ instances[this.uid].clients = [...(instances[this.uid].clients ?? []), client.uid];
73
+ await store.set('instances', instances);
74
+ }).catch((err) => {
75
+ useLogger().error(err, `[Collab] Failed to add client ${client.uid} to registry`);
76
+ });
77
+ client.on('close', () => {
78
+ this.removeClient(client.uid);
79
+ });
80
+ }
81
+ removeClient(uid) {
82
+ delete this.clients[uid];
83
+ delete this.orders[uid];
84
+ this.store(async (store) => {
85
+ const instances = await store.get('instances');
86
+ if (instances[this.uid]) {
87
+ instances[this.uid].clients = (instances[this.uid].clients ?? []).filter((clientId) => clientId !== uid);
88
+ await store.set('instances', instances);
89
+ }
90
+ }).catch((err) => {
91
+ useLogger().error(err, `[Collab] Failed to remove client ${uid} from registry`);
92
+ });
93
+ }
94
+ async registerRoom(uid) {
95
+ await this.store(async (store) => {
96
+ const instances = await store.get('instances');
97
+ if (!instances[this.uid])
98
+ instances[this.uid] = { clients: [], rooms: [] };
99
+ if (!instances[this.uid].rooms.includes(uid)) {
100
+ instances[this.uid].rooms.push(uid);
101
+ await store.set('instances', instances);
102
+ }
103
+ });
104
+ }
105
+ async unregisterRoom(uid) {
106
+ await this.store(async (store) => {
107
+ const instances = await store.get('instances');
108
+ if (instances[this.uid]) {
109
+ instances[this.uid].rooms = (instances[this.uid].rooms ?? []).filter((roomUid) => roomUid !== uid);
110
+ await store.set('instances', instances);
111
+ }
112
+ });
113
+ }
114
+ async getLocalClients() {
115
+ return Object.keys(this.clients);
116
+ }
117
+ async getGlobalClients() {
118
+ const instances = await this.store(async (store) => await store.get('instances'));
119
+ return Object.values(instances)
120
+ .map((instance) => instance.clients)
121
+ .flat();
122
+ }
123
+ async pruneDeadInstances() {
124
+ const instances = await this.store(async (store) => await store.get('instances'));
125
+ const inactiveInstances = new Set(Object.keys(instances));
126
+ inactiveInstances.delete(this.uid);
127
+ const pongCollector = (message) => {
128
+ if (message.type === 'pong') {
129
+ inactiveInstances.delete(message.instance);
130
+ }
131
+ };
132
+ this.messenger.subscribe(COLLAB_BUS, pongCollector);
133
+ for (const instance of inactiveInstances) {
134
+ this.messenger.publish(COLLAB_BUS, { type: 'ping', instance });
135
+ }
136
+ await new Promise((resolve) => {
137
+ setTimeout(resolve, INSTANCE_TIMEOUT);
138
+ });
139
+ this.messenger.unsubscribe(COLLAB_BUS, pongCollector);
140
+ const dead = { clients: [], rooms: [] };
141
+ if (inactiveInstances.size === 0) {
142
+ return {
143
+ inactive: dead,
144
+ active: Object.values(instances)
145
+ .map((instance) => instance.clients)
146
+ .flat(),
147
+ };
148
+ }
149
+ // Reread state to avoid overwriting updates during the timeout phase
150
+ const current = await this.store(async (store) => {
151
+ const current = await store.get('instances');
152
+ let changed = false;
153
+ for (const deadId of inactiveInstances) {
154
+ if (current[deadId]) {
155
+ dead.clients.push(...(current[deadId].clients ?? []));
156
+ dead.rooms.push(...(current[deadId].rooms ?? []));
157
+ delete current[deadId];
158
+ changed = true;
159
+ }
160
+ }
161
+ if (changed) {
162
+ await store.set('instances', current);
163
+ }
164
+ return current;
165
+ });
166
+ return {
167
+ inactive: dead,
168
+ active: Object.values(current)
169
+ .map((instance) => instance.clients)
170
+ .flat(),
171
+ };
172
+ }
173
+ sendRoom(room, message) {
174
+ this.messenger.publish(COLLAB_BUS, { type: 'room', room, ...message });
175
+ }
176
+ sendClient(client, message) {
177
+ const localClient = this.clients[client];
178
+ if (localClient) {
179
+ const order = this.orders[client] ?? 0;
180
+ this.orders[client] = order + 1;
181
+ localClient.send(JSON.stringify({ ...message, order }));
182
+ }
183
+ else {
184
+ this.messenger.publish(COLLAB_BUS, { type: 'send', client, message });
185
+ }
186
+ }
187
+ terminateClient(client) {
188
+ const localClient = this.clients[client];
189
+ if (localClient) {
190
+ // Allow message to flush before closing
191
+ setTimeout(() => {
192
+ localClient.close();
193
+ }, 250);
194
+ }
195
+ else {
196
+ this.messenger.publish(COLLAB_BUS, { type: 'terminate', client });
197
+ }
198
+ }
199
+ sendError(client, error) {
200
+ const localClient = this.clients[client];
201
+ if (localClient) {
202
+ localClient.send(JSON.stringify(error));
203
+ }
204
+ else {
205
+ this.messenger.publish(COLLAB_BUS, { type: 'error', client, message: error });
206
+ }
207
+ }
208
+ handleError(client, error, action) {
209
+ let message;
210
+ if (isDirectusError(error)) {
211
+ message = {
212
+ action: 'error',
213
+ type: WS_TYPE.COLLAB,
214
+ code: error.code,
215
+ trigger: action,
216
+ message: error.message,
217
+ };
218
+ }
219
+ else {
220
+ useLogger().error(`WebSocket unhandled exception ${JSON.stringify({ type: WS_TYPE.COLLAB, error })}`);
221
+ return;
222
+ }
223
+ this.sendError(client, message);
224
+ }
225
+ }
@@ -0,0 +1,18 @@
1
+ import type { Accountability, PrimaryKey, SchemaOverview } from '@directus/types';
2
+ import type { Knex } from 'knex';
3
+ type PermissionContext = {
4
+ knex: Knex;
5
+ schema: SchemaOverview;
6
+ accountability: Accountability | null;
7
+ itemId?: PrimaryKey | null;
8
+ direction?: 'inbound' | 'outbound';
9
+ };
10
+ /**
11
+ * Validates a changes payload against the user's update/create permissions and errors if unauthorized field is encountered
12
+ */
13
+ export declare function validateChanges(payload: any, collection: string, itemId: PrimaryKey | null, context: PermissionContext): Promise<any>;
14
+ /**
15
+ * Sanitizes a payload based on the recipient's read permissions and the schema
16
+ */
17
+ export declare function sanitizePayload(payload: any, collection: string, context: PermissionContext): Promise<any>;
18
+ export {};
@@ -0,0 +1,158 @@
1
+ import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
2
+ import { deepMapWithSchema, isDetailedUpdateSyntax } from '@directus/utils';
3
+ import { verifyPermissions } from './verify-permissions.js';
4
+ /**
5
+ * Validates a changes payload against the user's update/create permissions and errors if unauthorized field is encountered
6
+ */
7
+ export async function validateChanges(payload, collection, itemId, context) {
8
+ return processPermissions(payload, collection, { ...context, itemId, direction: 'inbound' });
9
+ }
10
+ /**
11
+ * Sanitizes a payload based on the recipient's read permissions and the schema
12
+ */
13
+ export async function sanitizePayload(payload, collection, context) {
14
+ return processPermissions(payload, collection, { ...context, direction: 'outbound' });
15
+ }
16
+ /**
17
+ * Core utility to walk a payload and apply permissions
18
+ */
19
+ async function processPermissions(payload, collection, context) {
20
+ const { direction, accountability, schema, knex, itemId } = context;
21
+ // Local cache for permissions to avoid redundant verifyPermissions calls for the same item:action pair
22
+ // The promise is cached, so concurrent field lookups for the same item wait for the same result
23
+ const permissionsCache = new Map();
24
+ const getPermissions = (col, id, action) => {
25
+ const cacheKey = `${col}:${id}:${action}`;
26
+ let cached = permissionsCache.get(cacheKey);
27
+ if (!cached) {
28
+ cached = verifyPermissions(accountability, col, id, action, { knex, schema });
29
+ permissionsCache.set(cacheKey, cached);
30
+ }
31
+ return cached;
32
+ };
33
+ return deepMapWithSchema(payload, async (entry, deepMapContext) => {
34
+ const [key, value] = entry;
35
+ if (direction === 'outbound') {
36
+ // Strip sensitive fields
37
+ if (deepMapContext.field?.special?.some((v) => v === 'conceal' || v === 'hash' || v === 'encrypt')) {
38
+ return undefined;
39
+ }
40
+ // Strip unknown leaf fields
41
+ if (deepMapContext.leaf && !deepMapContext.relation && !deepMapContext.field) {
42
+ return undefined;
43
+ }
44
+ }
45
+ if (value === undefined)
46
+ return undefined;
47
+ // Resolve the action (CRUD) and the ID to check against
48
+ const currentCollection = deepMapContext.collection.collection;
49
+ const pkField = deepMapContext.collection.primary;
50
+ const primaryKeyInObject = (deepMapContext.object[pkField] ?? null);
51
+ let action = direction === 'inbound' ? 'update' : 'read';
52
+ let effectiveItemId = primaryKeyInObject;
53
+ if (direction === 'inbound') {
54
+ const isTopLevel = deepMapContext.object === payload;
55
+ // At the top level, we use the ID from the request context (itemId)
56
+ // Deeply nested objects must provide their own ID for update checks
57
+ if (isTopLevel) {
58
+ effectiveItemId = itemId ?? null;
59
+ action = itemId ? 'update' : 'create';
60
+ }
61
+ else if (!primaryKeyInObject) {
62
+ action = 'create';
63
+ }
64
+ if (deepMapContext.action) {
65
+ action = deepMapContext.action;
66
+ }
67
+ }
68
+ else {
69
+ // sanitizePayload uses context.itemId as a fallback for the root item
70
+ if (deepMapContext.object === payload) {
71
+ effectiveItemId = primaryKeyInObject ?? itemId ?? null;
72
+ }
73
+ }
74
+ // Ensure no unexpected fields sneak into a delete operation
75
+ if (direction === 'inbound' && action === 'delete') {
76
+ if (key !== pkField) {
77
+ throw new InvalidPayloadError({ reason: `Unexpected field ${key} in delete payload` });
78
+ }
79
+ const allowed = await getPermissions(currentCollection, primaryKeyInObject, 'delete');
80
+ if (allowed === null || (allowed.length === 0 && !accountability?.admin)) {
81
+ throw new ForbiddenError({ reason: `No permission to delete item in collection ${currentCollection}` });
82
+ }
83
+ return;
84
+ }
85
+ // Allow PK field for identification on updates
86
+ if (direction === 'inbound' && action === 'update' && key === pkField) {
87
+ return;
88
+ }
89
+ let allowedFields = await getPermissions(currentCollection, effectiveItemId, action);
90
+ // Fallbacks
91
+ if (!allowedFields) {
92
+ if (direction === 'inbound' && action === 'update') {
93
+ // Toggle to create if update fails due to non-existence
94
+ action = 'create';
95
+ allowedFields = await getPermissions(currentCollection, effectiveItemId, action);
96
+ }
97
+ else if (direction === 'outbound') {
98
+ // Fall back to collection-wide read
99
+ allowedFields = (await getPermissions(currentCollection, null, 'read')) ?? [];
100
+ }
101
+ }
102
+ const isAllowed = allowedFields && (accountability?.admin || allowedFields.includes('*') || allowedFields.includes(String(key)));
103
+ if (!isAllowed) {
104
+ if (direction === 'inbound') {
105
+ throw new ForbiddenError({ reason: `No permission to ${action} field ${key} or field does not exist` });
106
+ }
107
+ return undefined;
108
+ }
109
+ // Remove the relation field entirely from the payload if it's empty after sanitizing its children
110
+ if (direction === 'outbound' && deepMapContext.relationType) {
111
+ if (Array.isArray(value)) {
112
+ const items = value.filter(isVisible);
113
+ if (items.length === 0)
114
+ return undefined;
115
+ return [key, items];
116
+ }
117
+ else if (isDetailedUpdateSyntax(value)) {
118
+ const filtered = {
119
+ ...value,
120
+ create: value.create.filter(isVisible),
121
+ update: value.update.filter(isVisible),
122
+ delete: value.delete.filter(isVisible),
123
+ };
124
+ if (filtered.create.length === 0 && filtered.update.length === 0 && filtered.delete.length === 0) {
125
+ return undefined;
126
+ }
127
+ return [key, filtered];
128
+ }
129
+ else if (!isVisible(value)) {
130
+ return undefined;
131
+ }
132
+ }
133
+ return [key, value];
134
+ }, {
135
+ schema,
136
+ collection,
137
+ }, {
138
+ detailedUpdateSyntax: true,
139
+ omitUnknownFields: direction === 'outbound',
140
+ mapPrimaryKeys: true,
141
+ processAsync: true,
142
+ iterateOnly: direction === 'inbound', // Validation only needs to check permissions, not rebuild the payload
143
+ onUnknownField: (entry) => {
144
+ const [key] = entry;
145
+ // Allow Directus internal metadata keys like $type
146
+ if (String(key).startsWith('$'))
147
+ return entry;
148
+ if (direction === 'inbound') {
149
+ throw new ForbiddenError({ reason: `No permission to update field ${key} or field does not exist` });
150
+ }
151
+ return undefined;
152
+ },
153
+ });
154
+ }
155
+ // Identifies non-empty or defined actionable content to avoid processing invalid relation links
156
+ function isVisible(item) {
157
+ return item !== undefined && !(typeof item === 'object' && item !== null && Object.keys(item).length === 0);
158
+ }
@@ -0,0 +1,52 @@
1
+ import type { Accountability } from '@directus/types';
2
+ /**
3
+ * Caches permission check results for collaborative editing clients.
4
+ * Supports granular invalidation based on collection, item, and relational dependencies.
5
+ */
6
+ export declare class PermissionCache {
7
+ private cache;
8
+ private tags;
9
+ private keyTags;
10
+ private timers;
11
+ private bus;
12
+ private invalidationCount;
13
+ constructor(maxSize: number);
14
+ /**
15
+ * Used for race condition protection during async permission fetches.
16
+ */
17
+ getInvalidationCount(): number;
18
+ /**
19
+ * Clears entire cache for system collections, or performs granular invalidation for user data.
20
+ */
21
+ private handleInvalidation;
22
+ /**
23
+ * Get cached allowed fields for a given accountability and collection/item.
24
+ * LRUMap automatically updates access order on get().
25
+ */
26
+ get(accountability: Accountability, collection: string, item: string | null, action: string): string[] | null | undefined;
27
+ /**
28
+ * Store allowed fields in the cache with optional TTL and dependencies.
29
+ */
30
+ set(accountability: Accountability, collection: string, item: string | null, action: string, fields: string[] | null, dependencies?: string[], ttlMs?: number): void;
31
+ /**
32
+ * Called before LRU eviction or explicit invalidation to prevent orphaned metadata.
33
+ */
34
+ private cleanupKeyMetadata;
35
+ /**
36
+ * Maintains bidirectional mappings: tag → keys and key → tags.
37
+ */
38
+ private addTag;
39
+ /**
40
+ * Cleans up metadata first, then removes from cache.
41
+ */
42
+ private invalidateKey;
43
+ /**
44
+ * Cache key format: user:collection:item:action
45
+ */
46
+ private getCacheKey;
47
+ /**
48
+ * Clear the entire cache.
49
+ */
50
+ clear(): void;
51
+ }
52
+ export declare const permissionCache: PermissionCache;
@@ -0,0 +1,204 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { LRUMapWithDelete } from 'mnemonist';
3
+ import { useBus } from '../../bus/index.js';
4
+ import { IRRELEVANT_COLLECTIONS } from './constants.js';
5
+ const env = useEnv();
6
+ /**
7
+ * Caches permission check results for collaborative editing clients.
8
+ * Supports granular invalidation based on collection, item, and relational dependencies.
9
+ */
10
+ export class PermissionCache {
11
+ cache;
12
+ tags = new Map();
13
+ keyTags = new Map();
14
+ timers = new Map();
15
+ bus = useBus();
16
+ invalidationCount = 0;
17
+ constructor(maxSize) {
18
+ this.cache = new LRUMapWithDelete(maxSize);
19
+ this.bus.subscribe('websocket.event', (event) => {
20
+ this.handleInvalidation(event);
21
+ });
22
+ }
23
+ /**
24
+ * Used for race condition protection during async permission fetches.
25
+ */
26
+ getInvalidationCount() {
27
+ return this.invalidationCount;
28
+ }
29
+ /**
30
+ * Clears entire cache for system collections, or performs granular invalidation for user data.
31
+ */
32
+ handleInvalidation(event) {
33
+ const { collection, keys, key } = event;
34
+ const items = keys || (key ? [key] : []);
35
+ const affectedKeys = new Set();
36
+ // System Invalidation (Roles, Permissions, Policies, Schema)
37
+ if ([
38
+ 'directus_roles',
39
+ 'directus_permissions',
40
+ 'directus_policies',
41
+ 'directus_access',
42
+ 'directus_fields',
43
+ 'directus_relations',
44
+ 'directus_collections',
45
+ ].includes(collection)) {
46
+ this.clear();
47
+ return;
48
+ }
49
+ // Skip known high-traffic collections
50
+ if (IRRELEVANT_COLLECTIONS.includes(collection)) {
51
+ return;
52
+ }
53
+ this.invalidationCount++;
54
+ // Prevent overflow issues in long-running processes
55
+ if (this.invalidationCount >= Number.MAX_SAFE_INTEGER) {
56
+ this.invalidationCount = 1;
57
+ }
58
+ // Skip if no keys are watching
59
+ if (!this.tags.has(`collection:${collection}`) &&
60
+ !this.tags.has(`dependency:${collection}`) &&
61
+ !this.tags.has(`collection-dependency:${collection}`)) {
62
+ return;
63
+ }
64
+ // Collection-level Invalidation
65
+ if (items.length === 0 && this.tags.has(`collection:${collection}`)) {
66
+ for (const k of this.tags.get(`collection:${collection}`))
67
+ affectedKeys.add(k);
68
+ }
69
+ // Item-level Invalidation
70
+ for (const id of items) {
71
+ const tag = `item:${collection}:${id}`;
72
+ if (this.tags.has(tag)) {
73
+ for (const k of this.tags.get(tag))
74
+ affectedKeys.add(k);
75
+ }
76
+ }
77
+ // Dependency Invalidation (Items + Relational)
78
+ const depTags = [`dependency:${collection}`];
79
+ if (items.length > 0) {
80
+ for (const id of items) {
81
+ depTags.push(`dependency:${collection}:${id}`);
82
+ }
83
+ }
84
+ else {
85
+ depTags.push(`collection-dependency:${collection}`);
86
+ }
87
+ for (const tag of depTags) {
88
+ if (this.tags.has(tag)) {
89
+ for (const k of this.tags.get(tag))
90
+ affectedKeys.add(k);
91
+ }
92
+ }
93
+ for (const k of affectedKeys) {
94
+ this.invalidateKey(k);
95
+ }
96
+ }
97
+ /**
98
+ * Get cached allowed fields for a given accountability and collection/item.
99
+ * LRUMap automatically updates access order on get().
100
+ */
101
+ get(accountability, collection, item, action) {
102
+ const key = this.getCacheKey(accountability, collection, item, action);
103
+ return this.cache.get(key);
104
+ }
105
+ /**
106
+ * Store allowed fields in the cache with optional TTL and dependencies.
107
+ */
108
+ set(accountability, collection, item, action, fields, dependencies = [], ttlMs) {
109
+ const key = this.getCacheKey(accountability, collection, item, action);
110
+ // Clear existing timer if any
111
+ if (this.timers.has(key)) {
112
+ clearTimeout(this.timers.get(key));
113
+ this.timers.delete(key);
114
+ }
115
+ // Clean up metadata for LRU eviction if at capacity
116
+ // LRUMapWithDelete auto-evicts, but we need to clean up our tag mappings
117
+ if (!this.cache.has(key) && this.cache.size >= this.cache.capacity) {
118
+ const lruKey = this.cache.keys().next().value;
119
+ if (lruKey) {
120
+ this.cleanupKeyMetadata(lruKey);
121
+ }
122
+ }
123
+ this.cache.set(key, fields);
124
+ if (ttlMs) {
125
+ const timer = setTimeout(() => {
126
+ this.invalidateKey(key);
127
+ }, ttlMs);
128
+ this.timers.set(key, timer);
129
+ }
130
+ // Always tag the specific item
131
+ this.addTag(key, `item:${collection}:${item}`);
132
+ // Always tag the collection to cover batch updates
133
+ this.addTag(key, `collection:${collection}`);
134
+ // Add custom dependencies such as relational collections
135
+ for (const dep of dependencies) {
136
+ this.addTag(key, `dependency:${dep}`);
137
+ if (dep.includes(':')) {
138
+ const [dependencyCollection] = dep.split(':');
139
+ this.addTag(key, `collection-dependency:${dependencyCollection}`);
140
+ }
141
+ }
142
+ }
143
+ /**
144
+ * Called before LRU eviction or explicit invalidation to prevent orphaned metadata.
145
+ */
146
+ cleanupKeyMetadata(key) {
147
+ if (this.timers.has(key)) {
148
+ clearTimeout(this.timers.get(key));
149
+ this.timers.delete(key);
150
+ }
151
+ const tags = this.keyTags.get(key);
152
+ if (tags) {
153
+ for (const tag of tags) {
154
+ const keys = this.tags.get(tag);
155
+ if (keys) {
156
+ keys.delete(key);
157
+ if (keys.size === 0)
158
+ this.tags.delete(tag);
159
+ }
160
+ }
161
+ this.keyTags.delete(key);
162
+ }
163
+ }
164
+ /**
165
+ * Maintains bidirectional mappings: tag → keys and key → tags.
166
+ */
167
+ addTag(key, tag) {
168
+ if (!this.tags.has(tag)) {
169
+ this.tags.set(tag, new Set());
170
+ }
171
+ this.tags.get(tag).add(key);
172
+ if (!this.keyTags.has(key)) {
173
+ this.keyTags.set(key, new Set());
174
+ }
175
+ this.keyTags.get(key).add(tag);
176
+ }
177
+ /**
178
+ * Cleans up metadata first, then removes from cache.
179
+ */
180
+ invalidateKey(key) {
181
+ this.cleanupKeyMetadata(key);
182
+ this.cache.delete(key);
183
+ }
184
+ /**
185
+ * Cache key format: user:collection:item:action
186
+ */
187
+ getCacheKey(accountability, collection, item, action) {
188
+ return `${accountability.user || 'public'}:${collection}:${item || 'singleton'}:${action}`;
189
+ }
190
+ /**
191
+ * Clear the entire cache.
192
+ */
193
+ clear() {
194
+ for (const timer of this.timers.values()) {
195
+ clearTimeout(timer);
196
+ }
197
+ this.timers.clear();
198
+ this.cache.clear();
199
+ this.tags.clear();
200
+ this.keyTags.clear();
201
+ this.invalidationCount++;
202
+ }
203
+ }
204
+ export const permissionCache = new PermissionCache(Number(env['WEBSOCKETS_COLLAB_PERMISSIONS_CACHE_CAPACITY'] ?? 2000));