@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,6 @@
1
+ import { ItemsService } from './items.js';
2
+ export class DeploymentRunsService extends ItemsService {
3
+ constructor(options) {
4
+ super('directus_deployment_runs', options);
5
+ }
6
+ }
@@ -0,0 +1,40 @@
1
+ import type { AbstractServiceOptions, CachedResult, DeploymentConfig, PrimaryKey, Project, ProviderType, Query } from '@directus/types';
2
+ import type { DeploymentDriver } from '../deployment/deployment.js';
3
+ import { ItemsService } from './items.js';
4
+ export declare class DeploymentService extends ItemsService<DeploymentConfig> {
5
+ constructor(options: AbstractServiceOptions);
6
+ createOne(data: Partial<DeploymentConfig>, opts?: any): Promise<PrimaryKey>;
7
+ updateOne(key: PrimaryKey, data: Partial<DeploymentConfig>, opts?: any): Promise<PrimaryKey>;
8
+ /**
9
+ * Read deployment config by provider
10
+ */
11
+ readByProvider(provider: ProviderType, query?: Query): Promise<DeploymentConfig>;
12
+ /**
13
+ * Update deployment config by provider
14
+ */
15
+ updateByProvider(provider: ProviderType, data: Partial<DeploymentConfig>): Promise<PrimaryKey>;
16
+ /**
17
+ * Delete deployment config by provider
18
+ */
19
+ deleteByProvider(provider: ProviderType): Promise<PrimaryKey>;
20
+ /**
21
+ * Read deployment config with decrypted credentials (internal use)
22
+ */
23
+ private readConfig;
24
+ /**
25
+ * Parse JSON string or return value as-is
26
+ */
27
+ private parseValue;
28
+ /**
29
+ * Get a deployment driver instance with decrypted credentials
30
+ */
31
+ getDriver(provider: ProviderType): Promise<DeploymentDriver>;
32
+ /**
33
+ * List projects from provider with caching
34
+ */
35
+ listProviderProjects(provider: ProviderType): Promise<CachedResult<Project[]>>;
36
+ /**
37
+ * Get project details from provider with caching
38
+ */
39
+ getProviderProject(provider: ProviderType, projectId: string): Promise<CachedResult<Project>>;
40
+ }
@@ -0,0 +1,202 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { InvalidPayloadError, InvalidProviderConfigError } from '@directus/errors';
3
+ import { mergeFilters, parseJSON } from '@directus/utils';
4
+ import { has, isEmpty } from 'lodash-es';
5
+ import { getCache, getCacheValueWithTTL, setCacheValueWithExpiry } from '../cache.js';
6
+ import { getDeploymentDriver } from '../deployment.js';
7
+ import { getMilliseconds } from '../utils/get-milliseconds.js';
8
+ import { ItemsService } from './items.js';
9
+ const env = useEnv();
10
+ const DEPLOYMENT_CACHE_TTL = getMilliseconds(env['CACHE_DEPLOYMENT_TTL']) || 5000; // Default 5s
11
+ export class DeploymentService extends ItemsService {
12
+ constructor(options) {
13
+ super('directus_deployments', options);
14
+ }
15
+ async createOne(data, opts) {
16
+ const provider = data.provider;
17
+ if (!provider) {
18
+ throw new InvalidPayloadError({ reason: 'Provider is required' });
19
+ }
20
+ if (isEmpty(data.credentials)) {
21
+ throw new InvalidPayloadError({ reason: 'Credentials are required' });
22
+ }
23
+ let credentials;
24
+ try {
25
+ credentials = this.parseValue(data.credentials, {});
26
+ }
27
+ catch {
28
+ throw new InvalidPayloadError({ reason: 'Credentials must be valid JSON' });
29
+ }
30
+ let options;
31
+ try {
32
+ options = this.parseValue(data.options, undefined);
33
+ }
34
+ catch {
35
+ throw new InvalidPayloadError({ reason: 'Options must be valid JSON' });
36
+ }
37
+ // Test connection before persisting
38
+ const driver = getDeploymentDriver(provider, credentials, options);
39
+ try {
40
+ await driver.testConnection();
41
+ }
42
+ catch {
43
+ throw new InvalidProviderConfigError({ provider, reason: 'Invalid config connection' });
44
+ }
45
+ const payload = {
46
+ ...data,
47
+ // Persist as string so payload service encrypts the value
48
+ credentials: JSON.stringify(credentials),
49
+ };
50
+ if (!isEmpty(options)) {
51
+ payload.options = JSON.stringify(options);
52
+ }
53
+ return super.createOne(payload, opts);
54
+ }
55
+ async updateOne(key, data, opts) {
56
+ const hasCredentials = has(data, 'credentials');
57
+ const hasOptions = has(data, 'options');
58
+ if (!hasCredentials && !hasOptions) {
59
+ return super.updateOne(key, data, opts);
60
+ }
61
+ const existing = await this.readOne(key);
62
+ const provider = existing.provider;
63
+ const internal = await this.readConfig(provider);
64
+ let credentials = this.parseValue(internal.credentials, {});
65
+ if (hasCredentials) {
66
+ try {
67
+ const parsed = this.parseValue(data.credentials, {});
68
+ credentials = { ...credentials, ...parsed };
69
+ }
70
+ catch {
71
+ throw new InvalidPayloadError({ reason: 'Credentials must be valid JSON or object' });
72
+ }
73
+ }
74
+ let options = existing.options ?? undefined;
75
+ if (hasOptions) {
76
+ try {
77
+ options = this.parseValue(data.options, undefined);
78
+ }
79
+ catch {
80
+ throw new InvalidPayloadError({ reason: 'Options must be valid JSON' });
81
+ }
82
+ if (isEmpty(options)) {
83
+ throw new InvalidPayloadError({ reason: 'Options must not be empty' });
84
+ }
85
+ }
86
+ // Test connection before persisting
87
+ const driver = getDeploymentDriver(provider, credentials, options);
88
+ try {
89
+ await driver.testConnection();
90
+ }
91
+ catch {
92
+ throw new InvalidProviderConfigError({ provider, reason: 'Invalid config connection' });
93
+ }
94
+ return super.updateOne(key, {
95
+ credentials: JSON.stringify(credentials),
96
+ ...(!isEmpty(options) ? { options: JSON.stringify(options) } : {}),
97
+ }, opts);
98
+ }
99
+ /**
100
+ * Read deployment config by provider
101
+ */
102
+ async readByProvider(provider, query) {
103
+ const results = await this.readByQuery({
104
+ ...query,
105
+ filter: mergeFilters({ provider: { _eq: provider } }, query?.filter ?? null),
106
+ limit: 1,
107
+ });
108
+ if (!results || results.length === 0) {
109
+ throw new Error(`Deployment config for "${provider}" not found`);
110
+ }
111
+ return results[0];
112
+ }
113
+ /**
114
+ * Update deployment config by provider
115
+ */
116
+ async updateByProvider(provider, data) {
117
+ const deployment = await this.readByProvider(provider);
118
+ return this.updateOne(deployment.id, data);
119
+ }
120
+ /**
121
+ * Delete deployment config by provider
122
+ */
123
+ async deleteByProvider(provider) {
124
+ const deployment = await this.readByProvider(provider);
125
+ return this.deleteOne(deployment.id);
126
+ }
127
+ /**
128
+ * Read deployment config with decrypted credentials (internal use)
129
+ */
130
+ async readConfig(provider) {
131
+ const internalService = new ItemsService('directus_deployments', {
132
+ knex: this.knex,
133
+ schema: this.schema,
134
+ accountability: null,
135
+ });
136
+ const results = await internalService.readByQuery({
137
+ filter: { provider: { _eq: provider } },
138
+ limit: 1,
139
+ });
140
+ if (!results || results.length === 0) {
141
+ throw new Error(`Deployment config for "${provider}" not found`);
142
+ }
143
+ return results[0];
144
+ }
145
+ /**
146
+ * Parse JSON string or return value as-is
147
+ */
148
+ parseValue(value, fallback) {
149
+ if (!value)
150
+ return fallback;
151
+ if (typeof value === 'string')
152
+ return parseJSON(value);
153
+ return value;
154
+ }
155
+ /**
156
+ * Get a deployment driver instance with decrypted credentials
157
+ */
158
+ async getDriver(provider) {
159
+ const deployment = await this.readConfig(provider);
160
+ const credentials = this.parseValue(deployment.credentials, {});
161
+ const options = this.parseValue(deployment.options, {});
162
+ return getDeploymentDriver(deployment.provider, credentials, options);
163
+ }
164
+ /**
165
+ * List projects from provider with caching
166
+ */
167
+ async listProviderProjects(provider) {
168
+ const cacheKey = `${provider}:projects`;
169
+ const { deploymentCache } = getCache();
170
+ // Check cache first
171
+ const cached = await getCacheValueWithTTL(deploymentCache, cacheKey);
172
+ if (cached) {
173
+ return { data: cached.data, remainingTTL: cached.remainingTTL };
174
+ }
175
+ // Fetch from driver
176
+ const driver = await this.getDriver(provider);
177
+ const projects = await driver.listProjects();
178
+ // Store in cache
179
+ await setCacheValueWithExpiry(deploymentCache, cacheKey, projects, DEPLOYMENT_CACHE_TTL);
180
+ // Return with full TTL (just cached)
181
+ return { data: projects, remainingTTL: DEPLOYMENT_CACHE_TTL };
182
+ }
183
+ /**
184
+ * Get project details from provider with caching
185
+ */
186
+ async getProviderProject(provider, projectId) {
187
+ const cacheKey = `${provider}:project:${projectId}`;
188
+ const { deploymentCache } = getCache();
189
+ // Check cache first
190
+ const cached = await getCacheValueWithTTL(deploymentCache, cacheKey);
191
+ if (cached) {
192
+ return { data: cached.data, remainingTTL: cached.remainingTTL };
193
+ }
194
+ // Fetch from driver
195
+ const driver = await this.getDriver(provider);
196
+ const project = await driver.getProject(projectId);
197
+ // Store in cache
198
+ await setCacheValueWithExpiry(deploymentCache, cacheKey, project, DEPLOYMENT_CACHE_TTL);
199
+ // Return with full TTL (just cached)
200
+ return { data: project, remainingTTL: DEPLOYMENT_CACHE_TTL };
201
+ }
202
+ }
@@ -2,7 +2,6 @@ import { InvalidPayloadError } from '@directus/errors';
2
2
  import { isSystemField } from '@directus/system-data';
3
3
  import { GraphQLBoolean, GraphQLID, GraphQLList, GraphQLNonNull, GraphQLString } from 'graphql';
4
4
  import { SchemaComposer, toInputObjectType } from 'graphql-compose';
5
- import { fromZodError } from 'zod-validation-error';
6
5
  import { CollectionsService } from '../../collections.js';
7
6
  import { ExtensionsService } from '../../extensions.js';
8
7
  import { FieldsService, systemFieldUpdateSchema } from '../../fields.js';
@@ -112,7 +111,7 @@ export function resolveSystemAdmin(gql, schema, schemaComposer) {
112
111
  if (isSystemField(args['collection'], args['field'])) {
113
112
  const validationResult = systemFieldUpdateSchema.safeParse(args['data']);
114
113
  if (!validationResult.success) {
115
- throw new InvalidPayloadError({ reason: fromZodError(validationResult.error).message });
114
+ throw new InvalidPayloadError({ reason: 'Only "schema.is_indexed" may be modified for system fields' });
116
115
  }
117
116
  }
118
117
  await service.updateField(args['collection'], {
@@ -140,7 +139,7 @@ export function resolveSystemAdmin(gql, schema, schemaComposer) {
140
139
  if (isSystemField(args['collection'], fieldData['field'])) {
141
140
  const validationResult = systemFieldUpdateSchema.safeParse(fieldData);
142
141
  if (!validationResult.success) {
143
- throw new InvalidPayloadError({ reason: fromZodError(validationResult.error).message });
142
+ throw new InvalidPayloadError({ reason: 'Only "schema.is_indexed" may be modified for system fields' });
144
143
  }
145
144
  }
146
145
  }
@@ -1,5 +1,4 @@
1
- import { getRelation } from '@directus/utils';
2
- import { getRelationType } from '../../../utils/get-relation-type.js';
1
+ import { getRelation, getRelationType } from '@directus/utils';
3
2
  export function filterReplaceM2A(filter_arg, collection, schema, options) {
4
3
  const filter = filter_arg;
5
4
  for (const key in filter) {
@@ -10,7 +9,7 @@ export function filterReplaceM2A(filter_arg, collection, schema, options) {
10
9
  continue;
11
10
  field = options?.aliasMap?.[field] ?? field;
12
11
  const relation = getRelation(schema.relations, collection, field);
13
- const type = relation ? getRelationType({ relation, collection, field }) : null;
12
+ const type = relation ? getRelationType({ relation, collection, field, useA2O: true }) : null;
14
13
  if (type === 'o2m' && relation) {
15
14
  filter[key] = filterReplaceM2A(filter[key], relation.collection, schema, options);
16
15
  }
@@ -46,7 +45,7 @@ export function filterReplaceM2ADeep(deep_arg, collection, schema, options) {
46
45
  const relation = getRelation(schema.relations, collection, field);
47
46
  if (!relation)
48
47
  continue;
49
- const type = getRelationType({ relation, collection, field });
48
+ const type = getRelationType({ relation, collection, field, useA2O: true });
50
49
  if (type === 'o2m') {
51
50
  deep[key] = filterReplaceM2ADeep(deep[key], relation.collection, schema);
52
51
  }
@@ -10,6 +10,9 @@ export * from './fields.js';
10
10
  export * from './files.js';
11
11
  export * from './flows.js';
12
12
  export * from './folders.js';
13
+ export * from './deployment.js';
14
+ export * from './deployment-projects.js';
15
+ export * from './deployment-runs.js';
13
16
  export * from './graphql/index.js';
14
17
  export * from './import-export.js';
15
18
  export * from './items.js';
@@ -10,6 +10,9 @@ export * from './fields.js';
10
10
  export * from './files.js';
11
11
  export * from './flows.js';
12
12
  export * from './folders.js';
13
+ export * from './deployment.js';
14
+ export * from './deployment-projects.js';
15
+ export * from './deployment-runs.js';
13
16
  export * from './graphql/index.js';
14
17
  export * from './import-export.js';
15
18
  export * from './items.js';
@@ -103,6 +103,7 @@ export class ServerService {
103
103
  info['websocket'].heartbeat = toBoolean(env['WEBSOCKETS_HEARTBEAT_ENABLED'])
104
104
  ? env['WEBSOCKETS_HEARTBEAT_PERIOD']
105
105
  : false;
106
+ info['websocket'].collaborativeEditing = toBoolean(env['WEBSOCKETS_COLLAB_ENABLED']);
106
107
  info['websocket'].logs =
107
108
  toBoolean(env['WEBSOCKETS_LOGS_ENABLED']) && this.accountability.admin
108
109
  ? {
@@ -2,7 +2,7 @@ import { useEnv } from '@directus/env';
2
2
  import formatTitle from '@directus/format-title';
3
3
  import { spec } from '@directus/specs';
4
4
  import { isSystemCollection } from '@directus/system-data';
5
- import { getRelation } from '@directus/utils';
5
+ import { getRelation, getRelationType } from '@directus/utils';
6
6
  import { cloneDeep, mergeWith } from 'lodash-es';
7
7
  import hash from 'object-hash';
8
8
  import { OAS_REQUIRED_SCHEMAS } from '../constants.js';
@@ -10,7 +10,6 @@ import getDatabase from '../database/index.js';
10
10
  import { fetchPermissions } from '../permissions/lib/fetch-permissions.js';
11
11
  import { fetchPolicies } from '../permissions/lib/fetch-policies.js';
12
12
  import { fetchAllowedFieldMap } from '../permissions/modules/fetch-allowed-field-map/fetch-allowed-field-map.js';
13
- import { getRelationType } from '../utils/get-relation-type.js';
14
13
  import { reduceSchema } from '../utils/reduce-schema.js';
15
14
  import { GraphQLService } from './graphql/index.js';
16
15
  const env = useEnv();
@@ -362,6 +361,7 @@ class OASSpecsService {
362
361
  relation,
363
362
  field: field.field,
364
363
  collection: collection,
364
+ useA2O: true,
365
365
  });
366
366
  if (relationType === 'm2o') {
367
367
  const relatedTag = tags.find((tag) => tag['x-collection'] === relation.related_collection);
@@ -1,5 +1,6 @@
1
1
  import { Action } from '@directus/constants';
2
2
  import { ForbiddenError, InvalidPayloadError, UnprocessableContentError } from '@directus/errors';
3
+ import { deepMapWithSchema } from '@directus/utils';
3
4
  import Joi from 'joi';
4
5
  import { assign, get, isEqual, isPlainObject, pick } from 'lodash-es';
5
6
  import objectHash from 'object-hash';
@@ -8,7 +9,6 @@ import { getHelpers } from '../database/helpers/index.js';
8
9
  import emitter from '../emitter.js';
9
10
  import { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
10
11
  import { shouldClearCache } from '../utils/should-clear-cache.js';
11
- import { deepMapWithSchema } from '../utils/versioning/deep-map-with-schema.js';
12
12
  import { splitRecursive } from '../utils/versioning/split-recursive.js';
13
13
  import { ActivityService } from './activity.js';
14
14
  import { ItemsService } from './items.js';
@@ -1,4 +1,5 @@
1
1
  import { useEnv } from '@directus/env';
2
+ import { toBoolean } from '@directus/utils';
2
3
  import { version } from 'directus/version';
3
4
  import { getHelpers } from '../../database/helpers/index.js';
4
5
  import { getDatabase, getDatabaseClient } from '../../database/index.js';
@@ -55,6 +56,7 @@ export const getReport = async () => {
55
56
  extensions: extensionsCounts.totalEnabled,
56
57
  database_size: databaseSize ?? 0,
57
58
  files_size_total: filesizes.total,
59
+ websockets_enabled: toBoolean(env['WEBSOCKETS_ENABLED'] ?? false),
58
60
  ...settings,
59
61
  };
60
62
  };
@@ -91,4 +91,12 @@ export interface TelemetryReport {
91
91
  * Number of Visual Editor URLs configured in the system
92
92
  */
93
93
  visual_editor_urls: number;
94
+ /**
95
+ * Whether collaborative editing is enabled
96
+ */
97
+ collaborative_editing_enabled: boolean;
98
+ /**
99
+ * Whether WebSockets are enabled
100
+ */
101
+ websockets_enabled: boolean;
94
102
  }
@@ -8,6 +8,7 @@ export type TelemetrySettings = {
8
8
  ai_openai_api_key: boolean;
9
9
  ai_anthropic_api_key: boolean;
10
10
  ai_system_prompt: boolean;
11
+ collaborative_editing_enabled: boolean;
11
12
  };
12
13
  export type DatabaseSettings = {
13
14
  project_id: string;
@@ -20,5 +21,6 @@ export type DatabaseSettings = {
20
21
  ai_openai_api_key?: string;
21
22
  ai_anthropic_api_key?: string;
22
23
  ai_system_prompt?: string;
24
+ collaborative_editing_enabled?: boolean;
23
25
  };
24
26
  export declare const getSettings: (db: Knex) => Promise<TelemetrySettings>;
@@ -1,6 +1,9 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { toBoolean } from '@directus/utils';
1
3
  import { SettingsService } from '../../services/settings.js';
2
4
  import { getSchema } from '../../utils/get-schema.js';
3
5
  export const getSettings = async (db) => {
6
+ const env = useEnv();
4
7
  const settingsService = new SettingsService({
5
8
  knex: db,
6
9
  schema: await getSchema({ database: db }),
@@ -15,6 +18,7 @@ export const getSettings = async (db) => {
15
18
  'ai_openai_api_key',
16
19
  'ai_anthropic_api_key',
17
20
  'ai_system_prompt',
21
+ 'collaborative_editing_enabled',
18
22
  ],
19
23
  }));
20
24
  return {
@@ -26,5 +30,6 @@ export const getSettings = async (db) => {
26
30
  ai_openai_api_key: Boolean(settings?.ai_openai_api_key),
27
31
  ai_anthropic_api_key: Boolean(settings?.ai_anthropic_api_key),
28
32
  ai_system_prompt: Boolean(settings?.ai_system_prompt),
33
+ collaborative_editing_enabled: toBoolean(env['WEBSOCKETS_COLLAB_ENABLED'] ?? true) && (settings?.collaborative_editing_enabled ?? false),
29
34
  };
30
35
  };
@@ -1,5 +1,5 @@
1
1
  import type { CollectionOverview, FieldOverview, Relation, SchemaOverview } from '@directus/types';
2
- import { type RelationInfo } from './get-relation-info.js';
2
+ import { type RelationInfo } from '@directus/utils';
3
3
  /**
4
4
  * Allows to deep map the response from the ItemsService with collection, field and relation context for each entry.
5
5
  * Bottom to Top depth first mapping of values.
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert';
2
2
  import { InvalidQueryError } from '@directus/errors';
3
+ import { getRelationInfo } from '@directus/utils';
3
4
  import { isPlainObject } from 'lodash-es';
4
- import { getRelationInfo } from './get-relation-info.js';
5
5
  /**
6
6
  * Allows to deep map the response from the ItemsService with collection, field and relation context for each entry.
7
7
  * Bottom to Top depth first mapping of values.
@@ -1,5 +1,5 @@
1
1
  import { InvalidQueryError } from '@directus/errors';
2
- import { getRelationInfo } from './get-relation-info.js';
2
+ import { getRelationInfo } from '@directus/utils';
3
3
  /**
4
4
  * Converts a Directus field list path to the correct SQL names based on the constructed alias map.
5
5
  * For example: ['author', 'role', 'name'] -> 'ljnsv.name'
@@ -1,5 +1,5 @@
1
1
  import { ForbiddenError } from '@directus/errors';
2
- import { AccessService, ActivityService, CommentsService, DashboardsService, FilesService, FlowsService, FoldersService, ItemsService, NotificationsService, OperationsService, PanelsService, PermissionsService, PoliciesService, PresetsService, RevisionsService, RolesService, SettingsService, SharesService, TranslationsService, UsersService, VersionsService, } from '../services/index.js';
2
+ import { AccessService, ActivityService, CommentsService, DashboardsService, DeploymentProjectsService, DeploymentRunsService, DeploymentService, FilesService, FlowsService, FoldersService, ItemsService, NotificationsService, OperationsService, PanelsService, PermissionsService, PoliciesService, PresetsService, RevisionsService, RolesService, SettingsService, SharesService, TranslationsService, UsersService, VersionsService, } from '../services/index.js';
3
3
  /**
4
4
  * Select the correct service for the given collection. This allows the individual services to run
5
5
  * their custom checks (f.e. it allows `UsersService` to prevent updating TFA secret from outside).
@@ -46,6 +46,12 @@ export function getService(collection, opts) {
46
46
  return new UsersService(opts);
47
47
  case 'directus_versions':
48
48
  return new VersionsService(opts);
49
+ case 'directus_deployments':
50
+ return new DeploymentService(opts);
51
+ case 'directus_deployment_projects':
52
+ return new DeploymentProjectsService(opts);
53
+ case 'directus_deployment_runs':
54
+ return new DeploymentRunsService(opts);
49
55
  default:
50
56
  // Deny usage of other system collections via ItemsService
51
57
  if (collection.startsWith('directus_'))
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Check if a specific field is allowed within a set of allowed fields
3
+ */
4
+ export declare function isFieldAllowed(allowedFields: string[] | Set<string>, field: string): boolean;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Check if a specific field is allowed within a set of allowed fields
3
+ */
4
+ export function isFieldAllowed(allowedFields, field) {
5
+ if (Array.isArray(allowedFields)) {
6
+ return allowedFields.includes(field) || allowedFields.includes('*');
7
+ }
8
+ return allowedFields.has(field) || allowedFields.has('*');
9
+ }
@@ -1,6 +1,6 @@
1
1
  import { ForbiddenError } from '@directus/errors';
2
+ import { deepMapWithSchema } from '@directus/utils';
2
3
  import { transaction } from '../transaction.js';
3
- import { deepMapWithSchema } from './deep-map-with-schema.js';
4
4
  import { splitRecursive } from './split-recursive.js';
5
5
  export async function handleVersion(self, key, queryWithKey, opts) {
6
6
  const { VersionsService } = await import('../../services/versions.js');
@@ -0,0 +1,9 @@
1
+ import type { Accountability, SchemaOverview } from '@directus/types';
2
+ export declare const DYNAMIC_VARIABLE_MAP: Record<string, string>;
3
+ /**
4
+ * Calculate logical expiry (TTL) and dependencies for permissions caching.
5
+ */
6
+ export declare function calculateCacheMetadata(collection: string, itemData: any, rawPermissions: any[], schema: SchemaOverview, accountability: Accountability): {
7
+ ttlMs: number | undefined;
8
+ dependencies: string[];
9
+ };
@@ -0,0 +1,121 @@
1
+ import { REGEX_BETWEEN_PARENS } from '@directus/constants';
2
+ import { adjustDate } from '@directus/utils';
3
+ export const DYNAMIC_VARIABLE_MAP = {
4
+ $CURRENT_USER: 'directus_users',
5
+ $CURRENT_ROLE: 'directus_roles',
6
+ $CURRENT_ROLES: 'directus_roles',
7
+ $CURRENT_POLICIES: 'directus_policies',
8
+ };
9
+ /**
10
+ * Calculate logical expiry (TTL) and dependencies for permissions caching.
11
+ */
12
+ export function calculateCacheMetadata(collection, itemData, rawPermissions, schema, accountability) {
13
+ let ttlMs;
14
+ const dependencies = new Set();
15
+ if (itemData) {
16
+ const now = Date.now();
17
+ let closestExpiry = Infinity;
18
+ // Scan permission filters for dynamic variables and relational dependencies to determine cache invalidation rules
19
+ const scan = (val, fieldKey, currentCollection = collection) => {
20
+ if (!val || typeof val !== 'object')
21
+ return;
22
+ for (const [key, value] of Object.entries(val)) {
23
+ // Parse dynamic variables
24
+ if (typeof value === 'string' && value.startsWith('$')) {
25
+ // $NOW requires calculating a logical expiry (TTL) based on the field value
26
+ if (value.startsWith('$NOW')) {
27
+ const field = fieldKey || key;
28
+ const dateValue = itemData[field];
29
+ if (dateValue) {
30
+ let ruleDate = new Date();
31
+ if (value.includes('(')) {
32
+ const adjustment = value.match(REGEX_BETWEEN_PARENS)?.[1];
33
+ if (adjustment)
34
+ ruleDate = adjustDate(ruleDate, adjustment) || ruleDate;
35
+ }
36
+ const adjustmentMs = ruleDate.getTime() - now;
37
+ const expiry = new Date(dateValue).getTime() - adjustmentMs;
38
+ if (expiry > now && expiry < closestExpiry) {
39
+ closestExpiry = expiry;
40
+ }
41
+ }
42
+ }
43
+ else {
44
+ // Other dynamic variables ($CURRENT_USER, etc) create collection-based dependencies
45
+ const parts = value.split('.');
46
+ const dynamicVariable = parts[0];
47
+ const rootCollection = DYNAMIC_VARIABLE_MAP[dynamicVariable];
48
+ if (rootCollection) {
49
+ // Only $CURRENT_USER needs granular tagging
50
+ // Other dynamic variables trigger full cache wipe so collection-level is sufficient
51
+ if (dynamicVariable === '$CURRENT_USER' && accountability.user) {
52
+ dependencies.add(`${rootCollection}:${accountability.user}`);
53
+ }
54
+ else {
55
+ dependencies.add(rootCollection);
56
+ }
57
+ // Track all intermediate collections in the path
58
+ if (parts.length > 1) {
59
+ let currentCollection = rootCollection;
60
+ for (const segment of parts.slice(1, -1)) {
61
+ if (!currentCollection)
62
+ break;
63
+ const relation = schema.relations.find((r) => (r.collection === currentCollection && r.field === segment) ||
64
+ (r.related_collection === currentCollection && r.meta?.one_field === segment));
65
+ if (relation) {
66
+ currentCollection =
67
+ relation.collection === currentCollection
68
+ ? relation.related_collection
69
+ : relation.collection;
70
+ if (currentCollection)
71
+ dependencies.add(currentCollection);
72
+ }
73
+ else {
74
+ currentCollection = null;
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+ // Parse relational filter dependencies to track which collections affect this permission
82
+ let field = key;
83
+ if (key.includes('(') && key.includes(')')) {
84
+ const columnName = key.match(REGEX_BETWEEN_PARENS)?.[1];
85
+ if (columnName)
86
+ field = columnName;
87
+ }
88
+ if (!field.startsWith('_')) {
89
+ const relation = schema.relations.find((r) => (r.collection === currentCollection && r.field === field) ||
90
+ (r.related_collection === currentCollection && r.meta?.one_field === field));
91
+ let targetCol = null;
92
+ if (relation) {
93
+ targetCol = relation.collection === currentCollection ? relation.related_collection : relation.collection;
94
+ }
95
+ if (targetCol) {
96
+ dependencies.add(targetCol);
97
+ scan(value, undefined, targetCol);
98
+ }
99
+ else {
100
+ // Not a relation, but might be a nested filter object
101
+ scan(value, field.startsWith('_') ? fieldKey : field, currentCollection);
102
+ }
103
+ }
104
+ else {
105
+ // Keep scanning filter operators
106
+ scan(value, fieldKey, currentCollection);
107
+ }
108
+ }
109
+ };
110
+ // Scan all raw permissions to collect dependencies and calculate TTL
111
+ for (const permission of rawPermissions) {
112
+ scan(permission.permissions);
113
+ }
114
+ if (closestExpiry !== Infinity) {
115
+ ttlMs = closestExpiry - now;
116
+ // Limit TTL to between 1s and 1 hour
117
+ ttlMs = Math.max(1000, Math.min(ttlMs, 3600000));
118
+ }
119
+ }
120
+ return { ttlMs, dependencies: Array.from(dependencies) };
121
+ }