@directus/api 30.0.0 → 32.0.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 (197) hide show
  1. package/dist/app.js +7 -0
  2. package/dist/auth/auth.d.ts +2 -1
  3. package/dist/auth/auth.js +7 -2
  4. package/dist/auth/drivers/ldap.d.ts +0 -2
  5. package/dist/auth/drivers/ldap.js +9 -7
  6. package/dist/auth/drivers/oauth2.d.ts +0 -2
  7. package/dist/auth/drivers/oauth2.js +28 -11
  8. package/dist/auth/drivers/openid.d.ts +0 -2
  9. package/dist/auth/drivers/openid.js +28 -11
  10. package/dist/auth/drivers/saml.d.ts +0 -2
  11. package/dist/auth/drivers/saml.js +5 -5
  12. package/dist/auth.js +1 -2
  13. package/dist/cli/commands/bootstrap/index.js +12 -33
  14. package/dist/cli/commands/init/index.js +1 -1
  15. package/dist/cli/commands/schema/apply.d.ts +4 -0
  16. package/dist/cli/commands/schema/apply.js +26 -3
  17. package/dist/controllers/collections.js +7 -2
  18. package/dist/controllers/fields.js +31 -8
  19. package/dist/controllers/mcp.d.ts +2 -0
  20. package/dist/controllers/mcp.js +33 -0
  21. package/dist/controllers/server.js +26 -1
  22. package/dist/controllers/settings.js +9 -2
  23. package/dist/controllers/users.js +17 -7
  24. package/dist/controllers/versions.js +3 -2
  25. package/dist/database/errors/dialects/mssql.d.ts +1 -1
  26. package/dist/database/errors/dialects/mssql.js +18 -10
  27. package/dist/database/helpers/fn/types.js +3 -3
  28. package/dist/database/helpers/schema/dialects/cockroachdb.d.ts +2 -1
  29. package/dist/database/helpers/schema/dialects/cockroachdb.js +13 -0
  30. package/dist/database/helpers/schema/dialects/mssql.d.ts +2 -1
  31. package/dist/database/helpers/schema/dialects/mssql.js +23 -0
  32. package/dist/database/helpers/schema/dialects/mysql.d.ts +2 -1
  33. package/dist/database/helpers/schema/dialects/mysql.js +25 -0
  34. package/dist/database/helpers/schema/dialects/oracle.d.ts +2 -1
  35. package/dist/database/helpers/schema/dialects/oracle.js +13 -0
  36. package/dist/database/helpers/schema/dialects/postgres.d.ts +2 -1
  37. package/dist/database/helpers/schema/dialects/postgres.js +13 -0
  38. package/dist/database/helpers/schema/types.d.ts +5 -0
  39. package/dist/database/helpers/schema/types.js +6 -0
  40. package/dist/database/migrations/20250813A-add-mcp.d.ts +3 -0
  41. package/dist/database/migrations/20250813A-add-mcp.js +18 -0
  42. package/dist/database/migrations/20251012A-add-field-searchable.d.ts +3 -0
  43. package/dist/database/migrations/20251012A-add-field-searchable.js +10 -0
  44. package/dist/database/migrations/20251014A-add-project-owner.d.ts +3 -0
  45. package/dist/database/migrations/20251014A-add-project-owner.js +37 -0
  46. package/dist/database/migrations/20251028A-add-retention-indexes.d.ts +3 -0
  47. package/dist/database/migrations/20251028A-add-retention-indexes.js +42 -0
  48. package/dist/database/run-ast/README.md +46 -0
  49. package/dist/database/run-ast/lib/apply-query/add-join.js +2 -2
  50. package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
  51. package/dist/database/run-ast/lib/apply-query/index.d.ts +0 -1
  52. package/dist/database/run-ast/lib/apply-query/index.js +4 -6
  53. package/dist/database/run-ast/lib/apply-query/search.js +2 -0
  54. package/dist/database/run-ast/lib/get-db-query.js +7 -6
  55. package/dist/database/run-ast/utils/generate-alias.d.ts +6 -0
  56. package/dist/database/run-ast/utils/generate-alias.js +57 -0
  57. package/dist/flows.js +1 -0
  58. package/dist/mcp/define.d.ts +2 -0
  59. package/dist/mcp/define.js +3 -0
  60. package/dist/mcp/index.d.ts +1 -0
  61. package/dist/mcp/index.js +1 -0
  62. package/dist/mcp/schema.d.ts +485 -0
  63. package/dist/mcp/schema.js +219 -0
  64. package/dist/mcp/server.d.ts +103 -0
  65. package/dist/mcp/server.js +310 -0
  66. package/dist/mcp/tools/assets.d.ts +3 -0
  67. package/dist/mcp/tools/assets.js +54 -0
  68. package/dist/mcp/tools/collections.d.ts +84 -0
  69. package/dist/mcp/tools/collections.js +90 -0
  70. package/dist/mcp/tools/fields.d.ts +101 -0
  71. package/dist/mcp/tools/fields.js +157 -0
  72. package/dist/mcp/tools/files.d.ts +235 -0
  73. package/dist/mcp/tools/files.js +103 -0
  74. package/dist/mcp/tools/flows.d.ts +323 -0
  75. package/dist/mcp/tools/flows.js +85 -0
  76. package/dist/mcp/tools/folders.d.ts +95 -0
  77. package/dist/mcp/tools/folders.js +96 -0
  78. package/dist/mcp/tools/index.d.ts +15 -0
  79. package/dist/mcp/tools/index.js +29 -0
  80. package/dist/mcp/tools/items.d.ts +87 -0
  81. package/dist/mcp/tools/items.js +141 -0
  82. package/dist/mcp/tools/operations.d.ts +171 -0
  83. package/dist/mcp/tools/operations.js +77 -0
  84. package/dist/mcp/tools/prompts/assets.md +8 -0
  85. package/dist/mcp/tools/prompts/collections.md +336 -0
  86. package/dist/mcp/tools/prompts/fields.md +521 -0
  87. package/dist/mcp/tools/prompts/files.md +180 -0
  88. package/dist/mcp/tools/prompts/flows.md +495 -0
  89. package/dist/mcp/tools/prompts/folders.md +34 -0
  90. package/dist/mcp/tools/prompts/index.d.ts +16 -0
  91. package/dist/mcp/tools/prompts/index.js +19 -0
  92. package/dist/mcp/tools/prompts/items.md +317 -0
  93. package/dist/mcp/tools/prompts/operations.md +721 -0
  94. package/dist/mcp/tools/prompts/relations.md +386 -0
  95. package/dist/mcp/tools/prompts/schema.md +130 -0
  96. package/dist/mcp/tools/prompts/system-prompt-description.md +1 -0
  97. package/dist/mcp/tools/prompts/system-prompt.md +44 -0
  98. package/dist/mcp/tools/prompts/trigger-flow.md +214 -0
  99. package/dist/mcp/tools/relations.d.ts +73 -0
  100. package/dist/mcp/tools/relations.js +93 -0
  101. package/dist/mcp/tools/schema.d.ts +54 -0
  102. package/dist/mcp/tools/schema.js +317 -0
  103. package/dist/mcp/tools/system.d.ts +3 -0
  104. package/dist/mcp/tools/system.js +22 -0
  105. package/dist/mcp/tools/trigger-flow.d.ts +8 -0
  106. package/dist/mcp/tools/trigger-flow.js +48 -0
  107. package/dist/mcp/transport.d.ts +13 -0
  108. package/dist/mcp/transport.js +18 -0
  109. package/dist/mcp/types.d.ts +56 -0
  110. package/dist/mcp/types.js +1 -0
  111. package/dist/metrics/lib/create-metrics.js +16 -25
  112. package/dist/middleware/collection-exists.js +2 -2
  113. package/dist/operations/mail/index.js +3 -1
  114. package/dist/operations/mail/rate-limiter.d.ts +1 -0
  115. package/dist/operations/mail/rate-limiter.js +29 -0
  116. package/dist/permissions/modules/process-payload/process-payload.js +3 -10
  117. package/dist/permissions/modules/validate-access/validate-access.js +2 -3
  118. package/dist/schedules/metrics.js +6 -2
  119. package/dist/schedules/project.d.ts +4 -0
  120. package/dist/schedules/project.js +27 -0
  121. package/dist/services/authentication.js +36 -0
  122. package/dist/services/collections.d.ts +3 -3
  123. package/dist/services/collections.js +16 -1
  124. package/dist/services/fields.d.ts +21 -5
  125. package/dist/services/fields.js +109 -32
  126. package/dist/services/graphql/resolvers/query.js +1 -1
  127. package/dist/services/graphql/resolvers/system-admin.js +49 -5
  128. package/dist/services/graphql/schema/parse-query.js +8 -8
  129. package/dist/services/graphql/utils/aggregate-query.d.ts +1 -1
  130. package/dist/services/graphql/utils/aggregate-query.js +5 -1
  131. package/dist/services/graphql/utils/filter-replace-m2a.js +2 -1
  132. package/dist/services/import-export.d.ts +9 -1
  133. package/dist/services/import-export.js +287 -101
  134. package/dist/services/items.d.ts +1 -1
  135. package/dist/services/items.js +50 -24
  136. package/dist/services/mail/index.js +2 -0
  137. package/dist/services/mail/rate-limiter.d.ts +1 -0
  138. package/dist/services/mail/rate-limiter.js +29 -0
  139. package/dist/services/meta.js +28 -24
  140. package/dist/services/payload.d.ts +7 -3
  141. package/dist/services/payload.js +26 -12
  142. package/dist/services/schema.js +4 -1
  143. package/dist/services/server.d.ts +1 -0
  144. package/dist/services/server.js +15 -18
  145. package/dist/services/settings.d.ts +2 -1
  146. package/dist/services/settings.js +15 -0
  147. package/dist/services/tfa.d.ts +1 -1
  148. package/dist/services/tfa.js +20 -5
  149. package/dist/services/tus/server.js +14 -9
  150. package/dist/services/versions.d.ts +6 -4
  151. package/dist/services/versions.js +84 -25
  152. package/dist/telemetry/lib/get-report.js +4 -4
  153. package/dist/telemetry/lib/send-report.d.ts +6 -1
  154. package/dist/telemetry/lib/send-report.js +3 -1
  155. package/dist/telemetry/types/report.d.ts +17 -1
  156. package/dist/telemetry/utils/get-settings.d.ts +9 -0
  157. package/dist/telemetry/utils/get-settings.js +14 -0
  158. package/dist/test-utils/README.md +760 -0
  159. package/dist/test-utils/cache.d.ts +51 -0
  160. package/dist/test-utils/cache.js +59 -0
  161. package/dist/test-utils/database.d.ts +48 -0
  162. package/dist/test-utils/database.js +52 -0
  163. package/dist/test-utils/emitter.d.ts +35 -0
  164. package/dist/test-utils/emitter.js +38 -0
  165. package/dist/test-utils/fields-service.d.ts +28 -0
  166. package/dist/test-utils/fields-service.js +36 -0
  167. package/dist/test-utils/items-service.d.ts +23 -0
  168. package/dist/test-utils/items-service.js +37 -0
  169. package/dist/test-utils/knex.d.ts +164 -0
  170. package/dist/test-utils/knex.js +268 -0
  171. package/dist/test-utils/schema.d.ts +26 -0
  172. package/dist/test-utils/schema.js +35 -0
  173. package/dist/types/auth.d.ts +2 -3
  174. package/dist/utils/apply-diff.js +15 -0
  175. package/dist/utils/create-admin.d.ts +11 -0
  176. package/dist/utils/create-admin.js +50 -0
  177. package/dist/utils/get-schema.js +5 -3
  178. package/dist/utils/get-snapshot-diff.js +49 -5
  179. package/dist/utils/get-snapshot.js +13 -7
  180. package/dist/utils/sanitize-schema.d.ts +11 -4
  181. package/dist/utils/sanitize-schema.js +9 -6
  182. package/dist/utils/schedule.js +15 -19
  183. package/dist/utils/validate-diff.js +31 -0
  184. package/dist/utils/validate-snapshot.js +7 -0
  185. package/dist/utils/versioning/deep-map-with-schema.d.ts +23 -0
  186. package/dist/utils/versioning/deep-map-with-schema.js +81 -0
  187. package/dist/utils/versioning/handle-version.d.ts +2 -2
  188. package/dist/utils/versioning/handle-version.js +47 -43
  189. package/dist/utils/versioning/split-recursive.d.ts +4 -0
  190. package/dist/utils/versioning/split-recursive.js +27 -0
  191. package/dist/websocket/controllers/hooks.js +12 -20
  192. package/dist/websocket/messages.d.ts +3 -3
  193. package/package.json +65 -66
  194. package/dist/cli/utils/defaults.d.ts +0 -4
  195. package/dist/cli/utils/defaults.js +0 -17
  196. package/dist/telemetry/utils/get-project-id.d.ts +0 -2
  197. package/dist/telemetry/utils/get-project-id.js +0 -4
@@ -2,6 +2,7 @@ import { InvalidPayloadError } from '@directus/errors';
2
2
  import { authenticator } from 'otplib';
3
3
  import getDatabase from '../database/index.js';
4
4
  import { ItemsService } from './items.js';
5
+ import { DEFAULT_AUTH_PROVIDER } from '../constants.js';
5
6
  export class TFAService {
6
7
  knex;
7
8
  itemsService;
@@ -19,26 +20,40 @@ export class TFAService {
19
20
  }
20
21
  return authenticator.check(otp, user.tfa_secret);
21
22
  }
22
- async generateTFA(key) {
23
- const user = await this.knex.select('email', 'tfa_secret').from('directus_users').where({ id: key }).first();
23
+ async generateTFA(key, requiresPassword = true) {
24
+ const user = await this.knex
25
+ .select('email', 'tfa_secret', 'provider', 'external_identifier')
26
+ .from('directus_users')
27
+ .where({ id: key })
28
+ .first();
24
29
  if (user?.tfa_secret !== null) {
25
30
  throw new InvalidPayloadError({ reason: 'TFA Secret is already set for this user' });
26
31
  }
27
- if (!user?.email) {
32
+ // Only require email for non-OAuth users
33
+ if (user?.provider === DEFAULT_AUTH_PROVIDER && !user?.email) {
28
34
  throw new InvalidPayloadError({ reason: 'User must have a valid email to enable TFA' });
29
35
  }
36
+ if (!requiresPassword && user?.provider === DEFAULT_AUTH_PROVIDER) {
37
+ throw new InvalidPayloadError({ reason: 'This method is only available for OAuth users' });
38
+ }
30
39
  const secret = authenticator.generateSecret();
31
40
  const project = await this.knex.select('project_name').from('directus_settings').limit(1).first();
41
+ // For OAuth users without email, use external_identifier as fallback
42
+ const accountName = user.email || user.external_identifier || `user_${key}`;
32
43
  return {
33
44
  secret,
34
- url: authenticator.keyuri(user.email, project?.project_name || 'Directus', secret),
45
+ url: authenticator.keyuri(accountName, project?.project_name || 'Directus', secret),
35
46
  };
36
47
  }
37
48
  async enableTFA(key, otp, secret) {
38
- const user = await this.knex.select('tfa_secret').from('directus_users').where({ id: key }).first();
49
+ const user = await this.knex.select('tfa_secret', 'provider').from('directus_users').where({ id: key }).first();
50
+ const requiresPassword = user?.['provider'] === DEFAULT_AUTH_PROVIDER;
39
51
  if (user?.tfa_secret !== null) {
40
52
  throw new InvalidPayloadError({ reason: 'TFA Secret is already set for this user' });
41
53
  }
54
+ if (!requiresPassword && user?.provider === DEFAULT_AUTH_PROVIDER) {
55
+ throw new InvalidPayloadError({ reason: 'This method is only available for OAuth users' });
56
+ }
42
57
  if (!authenticator.check(otp, secret)) {
43
58
  throw new InvalidPayloadError({ reason: `"otp" is invalid` });
44
59
  }
@@ -6,7 +6,7 @@
6
6
  import { useEnv } from '@directus/env';
7
7
  import { supportsTus } from '@directus/storage';
8
8
  import { toArray } from '@directus/utils';
9
- import { Server, EVENTS } from '@tus/server';
9
+ import { Server } from '@tus/server';
10
10
  import { RESUMABLE_UPLOADS } from '../../constants.js';
11
11
  import { getStorage } from '../../storage/index.js';
12
12
  import { extractMetadata } from '../files/lib/extract-metadata.js';
@@ -16,6 +16,7 @@ import { getTusLocker } from './lockers.js';
16
16
  import { pick } from 'lodash-es';
17
17
  import emitter from '../../emitter.js';
18
18
  import getDatabase from '../../database/index.js';
19
+ import { getSchema } from '../../utils/get-schema.js';
19
20
  async function createTusStore(context) {
20
21
  const env = useEnv();
21
22
  const storage = await getStorage();
@@ -40,16 +41,17 @@ export async function createTusServer(context) {
40
41
  datastore: store,
41
42
  locker: getTusLocker(),
42
43
  ...(RESUMABLE_UPLOADS.MAX_SIZE !== null && { maxSize: RESUMABLE_UPLOADS.MAX_SIZE }),
43
- async onUploadFinish(req, res, upload) {
44
+ async onUploadFinish(req, upload) {
45
+ const schema = await getSchema();
44
46
  const service = new ItemsService('directus_files', {
45
- schema: req.schema,
47
+ schema,
46
48
  });
47
49
  const file = (await service.readByQuery({
48
50
  filter: { tus_id: { _eq: upload.id } },
49
51
  limit: 1,
50
52
  }))[0];
51
53
  if (!file)
52
- return res;
54
+ return {};
53
55
  let fileData;
54
56
  // update metadata when file is replaced
55
57
  if (file.tus_data?.['metadata']?.['replace_id']) {
@@ -91,19 +93,22 @@ export async function createTusServer(context) {
91
93
  collection: 'directus_files',
92
94
  }, {
93
95
  database: getDatabase(),
94
- schema: req.schema,
96
+ schema,
95
97
  accountability: req.accountability,
96
98
  });
97
- return res;
99
+ return {
100
+ headers: {
101
+ 'Directus-File-Id': upload.metadata['id'],
102
+ },
103
+ };
98
104
  },
99
105
  generateUrl(_req, opts) {
100
106
  return env['PUBLIC_URL'] + '/files/tus/' + opts.id;
101
107
  },
108
+ allowedHeaders: env['CORS_ALLOWED_HEADERS'],
109
+ exposedHeaders: env['CORS_EXPOSED_HEADERS'],
102
110
  relativeLocation: String(env['PUBLIC_URL']).startsWith('http'),
103
111
  });
104
- server.on(EVENTS.POST_CREATE, async (_req, res, upload) => {
105
- res.setHeader('Directus-File-Id', upload.metadata['id']);
106
- });
107
112
  return [server, cleanup];
108
113
  function cleanup() {
109
114
  server.removeAllListeners();
@@ -1,6 +1,6 @@
1
- import type { AbstractServiceOptions, Item, MutationOptions, PrimaryKey, Query } from '@directus/types';
1
+ import type { AbstractServiceOptions, ContentVersion, Item, MutationOptions, PrimaryKey, Query, QueryOptions } from '@directus/types';
2
2
  import { ItemsService } from './items.js';
3
- export declare class VersionsService extends ItemsService {
3
+ export declare class VersionsService extends ItemsService<ContentVersion> {
4
4
  constructor(options: AbstractServiceOptions);
5
5
  private validateCreateData;
6
6
  getMainItem(collection: string, item: PrimaryKey, query?: Query): Promise<Item>;
@@ -8,10 +8,12 @@ export declare class VersionsService extends ItemsService {
8
8
  outdated: boolean;
9
9
  mainHash: string;
10
10
  }>;
11
- getVersionSaves(key: string, collection: string, item: string | undefined): Promise<Partial<Item>[] | null>;
11
+ getVersionSave(key: string, collection: string, item: string, mapDelta?: boolean): Promise<ContentVersion | undefined>;
12
12
  createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey>;
13
+ readOne(key: PrimaryKey, query?: Query, opts?: QueryOptions): Promise<ContentVersion>;
13
14
  createMany(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]>;
14
15
  updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]>;
15
- save(key: PrimaryKey, data: Partial<Item>): Promise<Partial<Item>>;
16
+ save(key: PrimaryKey, delta: Partial<Item>): Promise<Partial<Item>>;
16
17
  promote(version: PrimaryKey, mainHash: string, fields?: string[]): Promise<PrimaryKey>;
18
+ private mapDelta;
17
19
  }
@@ -1,16 +1,19 @@
1
1
  import { Action } from '@directus/constants';
2
2
  import { ForbiddenError, InvalidPayloadError, UnprocessableContentError } from '@directus/errors';
3
3
  import Joi from 'joi';
4
- import { assign, pick } from 'lodash-es';
4
+ import { assign, get, isEqual, isPlainObject, pick } from 'lodash-es';
5
5
  import objectHash from 'object-hash';
6
6
  import { getCache } from '../cache.js';
7
+ import { getHelpers } from '../database/helpers/index.js';
7
8
  import emitter from '../emitter.js';
8
9
  import { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
9
10
  import { shouldClearCache } from '../utils/should-clear-cache.js';
11
+ import { splitRecursive } from '../utils/versioning/split-recursive.js';
10
12
  import { ActivityService } from './activity.js';
11
13
  import { ItemsService } from './items.js';
12
14
  import { PayloadService } from './payload.js';
13
15
  import { RevisionsService } from './revisions.js';
16
+ import { deepMapWithSchema } from '../utils/versioning/deep-map-with-schema.js';
14
17
  export class VersionsService extends ItemsService {
15
18
  constructor(options) {
16
19
  super('directus_versions', options);
@@ -59,10 +62,10 @@ export class VersionsService extends ItemsService {
59
62
  knex: this.knex,
60
63
  schema: this.schema,
61
64
  });
62
- const existingVersions = await sudoService.readByQuery({
65
+ const existingVersions = (await sudoService.readByQuery({
63
66
  aggregate: { count: ['*'] },
64
67
  filter: { key: { _eq: data['key'] }, collection: { _eq: data['collection'] }, item: { _eq: data['item'] } },
65
- });
68
+ }));
66
69
  if (existingVersions[0]['count'] > 0) {
67
70
  throw new UnprocessableContentError({
68
71
  reason: `Version "${data['key']}" already exists for item "${data['item']}" in collection "${data['collection']}"`,
@@ -82,21 +85,17 @@ export class VersionsService extends ItemsService {
82
85
  const mainHash = objectHash(mainItem);
83
86
  return { outdated: hash !== mainHash, mainHash };
84
87
  }
85
- async getVersionSaves(key, collection, item) {
86
- const filter = {
87
- key: { _eq: key },
88
- collection: { _eq: collection },
89
- };
90
- if (item) {
91
- filter['item'] = { _eq: item };
92
- }
93
- const versions = await this.readByQuery({ filter });
94
- if (!versions?.[0])
95
- return null;
96
- if (versions[0]['delta']) {
97
- return [versions[0]['delta']];
98
- }
99
- return null;
88
+ async getVersionSave(key, collection, item, mapDelta = true) {
89
+ const version = (await this.readByQuery({
90
+ filter: {
91
+ key: { _eq: key },
92
+ collection: { _eq: collection },
93
+ item: { _eq: item },
94
+ },
95
+ }))[0];
96
+ if (mapDelta && version?.delta)
97
+ version.delta = this.mapDelta(version);
98
+ return version;
100
99
  }
101
100
  async createOne(data, opts) {
102
101
  await this.validateCreateData(data);
@@ -104,6 +103,12 @@ export class VersionsService extends ItemsService {
104
103
  data['hash'] = objectHash(mainItem);
105
104
  return super.createOne(data, opts);
106
105
  }
106
+ async readOne(key, query = {}, opts) {
107
+ const version = await super.readOne(key, query, opts);
108
+ if (version?.delta)
109
+ version.delta = this.mapDelta(version);
110
+ return version;
111
+ }
107
112
  async createMany(data, opts) {
108
113
  if (!Array.isArray(data)) {
109
114
  throw new InvalidPayloadError({ reason: 'Input should be an array of items' });
@@ -156,7 +161,7 @@ export class VersionsService extends ItemsService {
156
161
  }
157
162
  return super.updateMany(keys, data, opts);
158
163
  }
159
- async save(key, data) {
164
+ async save(key, delta) {
160
165
  const version = await super.readOne(key);
161
166
  const payloadService = new PayloadService(this.collection, {
162
167
  accountability: this.accountability,
@@ -171,7 +176,7 @@ export class VersionsService extends ItemsService {
171
176
  knex: this.knex,
172
177
  schema: this.schema,
173
178
  });
174
- const { item, collection } = version;
179
+ const { item, collection, delta: existingDelta } = version;
175
180
  const activity = await activityService.createOne({
176
181
  action: Action.VERSION_SAVE,
177
182
  user: this.accountability?.user ?? null,
@@ -181,7 +186,8 @@ export class VersionsService extends ItemsService {
181
186
  origin: this.accountability?.origin ?? null,
182
187
  item,
183
188
  });
184
- const revisionDelta = await payloadService.prepareDelta(data);
189
+ const helpers = getHelpers(this.knex);
190
+ let revisionDelta = await payloadService.prepareDelta(delta);
185
191
  await revisionsService.createOne({
186
192
  activity,
187
193
  version: key,
@@ -190,10 +196,23 @@ export class VersionsService extends ItemsService {
190
196
  data: revisionDelta,
191
197
  delta: revisionDelta,
192
198
  });
193
- const finalVersionDelta = assign({}, version['delta'], revisionDelta ? JSON.parse(revisionDelta) : null);
199
+ revisionDelta = revisionDelta ? JSON.parse(revisionDelta) : null;
200
+ const date = new Date(helpers.date.writeTimestamp(new Date().toISOString()));
201
+ deepMapObjects(revisionDelta, (object, path) => {
202
+ const existing = get(existingDelta, path);
203
+ if (existing && isEqual(existing, object))
204
+ return;
205
+ object['_user'] = this.accountability?.user;
206
+ object['_date'] = date;
207
+ });
208
+ const finalVersionDelta = assign({}, existingDelta, revisionDelta);
194
209
  const sudoService = new ItemsService(this.collection, {
195
210
  knex: this.knex,
196
211
  schema: this.schema,
212
+ accountability: {
213
+ ...this.accountability,
214
+ admin: true,
215
+ },
197
216
  });
198
217
  await sudoService.updateOne(key, { delta: finalVersionDelta });
199
218
  const { cache } = getCache();
@@ -203,7 +222,7 @@ export class VersionsService extends ItemsService {
203
222
  return finalVersionDelta;
204
223
  }
205
224
  async promote(version, mainHash, fields) {
206
- const { collection, item, delta } = (await this.readOne(version));
225
+ const { collection, item, delta } = (await super.readOne(version));
207
226
  // will throw an error if the accountability does not have permission to update the item
208
227
  if (this.accountability) {
209
228
  await validateAccess({
@@ -227,7 +246,8 @@ export class VersionsService extends ItemsService {
227
246
  reason: `Main item has changed since this version was last updated`,
228
247
  });
229
248
  }
230
- const payloadToUpdate = fields ? pick(delta, fields) : delta;
249
+ const { rawDelta, defaultOverwrites } = splitRecursive(delta);
250
+ const payloadToUpdate = fields ? pick(rawDelta, fields) : rawDelta;
231
251
  const itemsService = new ItemsService(collection, {
232
252
  accountability: this.accountability,
233
253
  knex: this.knex,
@@ -242,7 +262,9 @@ export class VersionsService extends ItemsService {
242
262
  schema: this.schema,
243
263
  accountability: this.accountability,
244
264
  });
245
- const updatedItemKey = await itemsService.updateOne(item, payloadAfterHooks);
265
+ const updatedItemKey = await itemsService.updateOne(item, payloadAfterHooks, {
266
+ overwriteDefaults: defaultOverwrites,
267
+ });
246
268
  emitter.emitAction(['items.promote', `${collection}.items.promote`], {
247
269
  payload: payloadAfterHooks,
248
270
  collection,
@@ -255,4 +277,41 @@ export class VersionsService extends ItemsService {
255
277
  });
256
278
  return updatedItemKey;
257
279
  }
280
+ mapDelta(version) {
281
+ const delta = version.delta ?? {};
282
+ delta[this.schema.collections[version.collection].primary] = version.item;
283
+ return deepMapWithSchema(delta, ([key, value], context) => {
284
+ if (key === '_user' || key === '_date')
285
+ return;
286
+ if (context.collection.primary in context.object) {
287
+ if (context.field.special.includes('user-updated')) {
288
+ return [key, context.object['_user']];
289
+ }
290
+ if (context.field.special.includes('date-updated')) {
291
+ return [key, context.object['_date']];
292
+ }
293
+ }
294
+ else {
295
+ if (context.field.special.includes('user-created')) {
296
+ return [key, context.object['_user']];
297
+ }
298
+ if (context.field.special.includes('date-created')) {
299
+ return [key, context.object['_date']];
300
+ }
301
+ }
302
+ if (key in context.object)
303
+ return [key, value];
304
+ return undefined;
305
+ }, { collection: version.collection, schema: this.schema }, { mapNonExistentFields: true, detailedUpdateSyntax: true });
306
+ }
307
+ }
308
+ /** Deeply maps all objects of a structure. Only calls the callback for objects, not for arrays. Objects in arrays will continued to be mapped. */
309
+ function deepMapObjects(object, fn, path = []) {
310
+ if (isPlainObject(object) && typeof object === 'object' && object !== null) {
311
+ fn(object, path);
312
+ Object.entries(object).map(([key, value]) => deepMapObjects(value, fn, [...path, key]));
313
+ }
314
+ else if (Array.isArray(object)) {
315
+ object.map((value, index) => deepMapObjects(value, fn, [...path, String(index)]));
316
+ }
258
317
  }
@@ -7,8 +7,8 @@ import { getExtensionCount } from '../utils/get-extension-count.js';
7
7
  import { getFieldCount } from '../utils/get-field-count.js';
8
8
  import { getFilesizeSum } from '../utils/get-filesize-sum.js';
9
9
  import { getItemCount } from '../utils/get-item-count.js';
10
+ import { getSettings } from '../utils/get-settings.js';
10
11
  import { getUserItemCount } from '../utils/get-user-item-count.js';
11
- import { getProjectId } from '../utils/get-project-id.js';
12
12
  const basicCountTasks = [
13
13
  { collection: 'directus_dashboards' },
14
14
  { collection: 'directus_files' },
@@ -26,7 +26,7 @@ export const getReport = async () => {
26
26
  const db = getDatabase();
27
27
  const env = useEnv();
28
28
  const helpers = getHelpers(db);
29
- const [basicCounts, userCounts, userItemCount, fieldsCounts, extensionsCounts, databaseSize, filesizes, projectId] = await Promise.all([
29
+ const [basicCounts, userCounts, userItemCount, fieldsCounts, extensionsCounts, databaseSize, filesizes, settings] = await Promise.all([
30
30
  getItemCount(db, basicCountTasks),
31
31
  fetchUserCount({ knex: db }),
32
32
  getUserItemCount(db),
@@ -34,7 +34,7 @@ export const getReport = async () => {
34
34
  getExtensionCount(db),
35
35
  helpers.schema.getDatabaseSize(),
36
36
  getFilesizeSum(db),
37
- getProjectId(db),
37
+ getSettings(db),
38
38
  ]);
39
39
  return {
40
40
  url: env['PUBLIC_URL'],
@@ -55,6 +55,6 @@ export const getReport = async () => {
55
55
  extensions: extensionsCounts.totalEnabled,
56
56
  database_size: databaseSize ?? 0,
57
57
  files_size_total: filesizes.total,
58
- project_id: projectId,
58
+ ...settings,
59
59
  };
60
60
  };
@@ -1,5 +1,10 @@
1
1
  import type { TelemetryReport } from '../types/report.js';
2
+ import type { OwnerInformation } from '@directus/types';
3
+ export type OwnerReport = OwnerInformation & {
4
+ project_id: string;
5
+ version: string;
6
+ };
2
7
  /**
3
8
  * Post an anonymous usage report to the centralized intake server
4
9
  */
5
- export declare const sendReport: (report: TelemetryReport) => Promise<void>;
10
+ export declare const sendReport: (report: TelemetryReport | OwnerReport) => Promise<void>;
@@ -5,7 +5,9 @@ import { URL } from 'node:url';
5
5
  */
6
6
  export const sendReport = async (report) => {
7
7
  const env = useEnv();
8
- const url = new URL('/v1/metrics', env['TELEMETRY_URL']);
8
+ const url = 'project_owner' in report
9
+ ? new URL('/v1/owner', String(env['COMPLIANCE_URL']))
10
+ : new URL('/v1/metrics', String(env['TELEMETRY_URL']));
9
11
  const headers = {
10
12
  'Content-Type': 'application/json',
11
13
  };
@@ -74,5 +74,21 @@ export interface TelemetryReport {
74
74
  /**
75
75
  * Unique project identifier
76
76
  */
77
- project_id?: string | null;
77
+ project_id: string;
78
+ /**
79
+ * Whether the project has enabled MCP
80
+ */
81
+ mcp_enabled: boolean;
82
+ /**
83
+ * Whether the project allows deletes in MCP
84
+ */
85
+ mcp_allow_deletes: boolean;
86
+ /**
87
+ * Whether the project has enabled MCP system prompt
88
+ */
89
+ mcp_system_prompt_enabled: boolean;
90
+ /**
91
+ * Number of Visual Editor URLs configured in the system
92
+ */
93
+ visual_editor_urls: number;
78
94
  }
@@ -0,0 +1,9 @@
1
+ import type { Knex } from 'knex';
2
+ export type TelemetrySettings = {
3
+ project_id: string;
4
+ mcp_enabled: boolean;
5
+ mcp_allow_deletes: boolean;
6
+ mcp_system_prompt_enabled: boolean;
7
+ visual_editor_urls: number;
8
+ };
9
+ export declare const getSettings: (db: Knex) => Promise<TelemetrySettings>;
@@ -0,0 +1,14 @@
1
+ import { toBoolean } from '@directus/utils';
2
+ export const getSettings = async (db) => {
3
+ const settings = await db
4
+ .select('project_id', 'mcp_enabled', 'mcp_allow_deletes', 'mcp_system_prompt_enabled', 'visual_editor_urls')
5
+ .from('directus_settings')
6
+ .first();
7
+ return {
8
+ project_id: settings.project_id,
9
+ mcp_enabled: toBoolean(settings?.mcp_enabled),
10
+ mcp_allow_deletes: toBoolean(settings?.mcp_allow_deletes),
11
+ mcp_system_prompt_enabled: toBoolean(settings?.mcp_system_prompt_enabled),
12
+ visual_editor_urls: settings.visual_editor_urls ? JSON.parse(settings.visual_editor_urls).length : 0,
13
+ };
14
+ };