@directus/api 32.1.1 → 32.2.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 (165) hide show
  1. package/dist/ai/chat/constants/system-prompt.d.ts +1 -0
  2. package/dist/ai/chat/constants/system-prompt.js +51 -0
  3. package/dist/ai/chat/controllers/chat.post.d.ts +2 -0
  4. package/dist/ai/chat/controllers/chat.post.js +47 -0
  5. package/dist/ai/chat/lib/create-ui-stream.d.ts +15 -0
  6. package/dist/ai/chat/lib/create-ui-stream.js +42 -0
  7. package/dist/ai/chat/middleware/load-settings.d.ts +2 -0
  8. package/dist/ai/chat/middleware/load-settings.js +18 -0
  9. package/dist/ai/chat/models/chat-request.d.ts +34 -0
  10. package/dist/ai/chat/models/chat-request.js +26 -0
  11. package/dist/ai/chat/models/providers.d.ts +9 -0
  12. package/dist/ai/chat/models/providers.js +9 -0
  13. package/dist/ai/chat/router.d.ts +1 -0
  14. package/dist/ai/chat/router.js +5 -0
  15. package/dist/ai/chat/utils/chat-request-tool-to-ai-sdk-tool.d.ts +9 -0
  16. package/dist/ai/chat/utils/chat-request-tool-to-ai-sdk-tool.js +38 -0
  17. package/dist/ai/chat/utils/fix-error-tool-calls.d.ts +12 -0
  18. package/dist/ai/chat/utils/fix-error-tool-calls.js +30 -0
  19. package/dist/ai/chat/utils/parse-json-schema-7.d.ts +13 -0
  20. package/dist/ai/chat/utils/parse-json-schema-7.js +75 -0
  21. package/dist/{mcp → ai/mcp}/server.d.ts +13 -16
  22. package/dist/{mcp → ai/mcp}/server.js +4 -13
  23. package/dist/ai/mcp/types.d.ts +15 -0
  24. package/dist/{mcp/tools/assets.js → ai/tools/assets/index.js} +8 -5
  25. package/dist/{mcp/tools/collections.js → ai/tools/collections/index.js} +7 -4
  26. package/dist/{mcp/tools/fields.js → ai/tools/fields/index.js} +12 -9
  27. package/dist/{mcp/tools/files.js → ai/tools/files/index.js} +11 -5
  28. package/dist/{mcp/tools/flows.js → ai/tools/flows/index.js} +11 -5
  29. package/dist/{mcp/tools/folders.js → ai/tools/folders/index.js} +12 -5
  30. package/dist/ai/tools/index.d.ts +15 -0
  31. package/dist/ai/tools/index.js +29 -0
  32. package/dist/{mcp/tools/items.js → ai/tools/items/index.js} +13 -6
  33. package/dist/{mcp/tools/prompts/items.md → ai/tools/items/prompt.md} +19 -15
  34. package/dist/{mcp/tools/operations.d.ts → ai/tools/operations/index.d.ts} +46 -0
  35. package/dist/{mcp/tools/operations.js → ai/tools/operations/index.js} +12 -5
  36. package/dist/{mcp/tools/relations.js → ai/tools/relations/index.js} +7 -4
  37. package/dist/{mcp/tools/schema.d.ts → ai/tools/schema/index.d.ts} +1 -1
  38. package/dist/{mcp/tools/schema.js → ai/tools/schema/index.js} +9 -6
  39. package/dist/{mcp/tools/system.js → ai/tools/system/index.js} +7 -4
  40. package/dist/{mcp/tools/trigger-flow.js → ai/tools/trigger-flow/index.js} +8 -5
  41. package/dist/{mcp → ai/tools}/types.d.ts +1 -17
  42. package/dist/ai/tools/utils.d.ts +9 -0
  43. package/dist/ai/tools/utils.js +17 -0
  44. package/dist/app.js +5 -0
  45. package/dist/auth/drivers/saml.js +5 -2
  46. package/dist/controllers/assets.js +39 -2
  47. package/dist/controllers/mcp.js +1 -1
  48. package/dist/database/migrations/20240806A-permissions-policies.js +2 -2
  49. package/dist/database/migrations/20251103A-add-ai-settings.d.ts +3 -0
  50. package/dist/database/migrations/20251103A-add-ai-settings.js +14 -0
  51. package/dist/database/run-ast/run-ast.js +1 -1
  52. package/dist/extensions/lib/installation/manager.js +5 -9
  53. package/dist/extensions/lib/sync/status.d.ts +11 -0
  54. package/dist/extensions/lib/sync/status.js +34 -0
  55. package/dist/extensions/lib/sync/sync.d.ts +6 -0
  56. package/dist/extensions/lib/sync/sync.js +90 -0
  57. package/dist/extensions/lib/sync/tracker.d.ts +18 -0
  58. package/dist/extensions/lib/sync/tracker.js +71 -0
  59. package/dist/extensions/lib/sync/utils.d.ts +24 -0
  60. package/dist/extensions/lib/sync/utils.js +62 -0
  61. package/dist/extensions/manager.d.ts +8 -4
  62. package/dist/extensions/manager.js +30 -13
  63. package/dist/middleware/respond.js +2 -2
  64. package/dist/permissions/lib/fetch-policies.d.ts +1 -1
  65. package/dist/permissions/lib/fetch-roles-tree.d.ts +6 -3
  66. package/dist/permissions/lib/fetch-roles-tree.js +5 -27
  67. package/dist/permissions/modules/fetch-global-access/fetch-global-access.d.ts +9 -7
  68. package/dist/permissions/modules/fetch-global-access/fetch-global-access.js +17 -9
  69. package/dist/permissions/modules/fetch-policies-ip-access/fetch-policies-ip-access.d.ts +1 -1
  70. package/dist/permissions/utils/fetch-raw-permissions.d.ts +1 -1
  71. package/dist/permissions/utils/fetch-share-info.d.ts +1 -1
  72. package/dist/permissions/utils/fetch-share-info.js +1 -1
  73. package/dist/permissions/utils/filter-policies-by-ip.js +1 -1
  74. package/dist/permissions/utils/get-permissions-for-share.js +8 -8
  75. package/dist/permissions/utils/with-cache.d.ts +8 -6
  76. package/dist/permissions/utils/with-cache.js +12 -10
  77. package/dist/request/is-denied-ip.js +2 -2
  78. package/dist/services/assets/name-deduper.d.ts +7 -0
  79. package/dist/services/assets/name-deduper.js +23 -0
  80. package/dist/services/assets.d.ts +15 -2
  81. package/dist/services/assets.js +98 -5
  82. package/dist/services/authentication.js +4 -4
  83. package/dist/services/comments.js +2 -2
  84. package/dist/services/extensions.js +4 -0
  85. package/dist/services/folders.d.ts +27 -2
  86. package/dist/services/folders.js +75 -0
  87. package/dist/services/import-export.d.ts +1 -1
  88. package/dist/services/import-export.js +4 -5
  89. package/dist/services/notifications.js +2 -2
  90. package/dist/services/payload.js +20 -0
  91. package/dist/services/roles.js +2 -2
  92. package/dist/services/tus/server.js +3 -3
  93. package/dist/telemetry/utils/get-settings.d.ts +15 -0
  94. package/dist/telemetry/utils/get-settings.js +13 -1
  95. package/dist/test-utils/README.md +95 -24
  96. package/dist/test-utils/cache.d.ts +2 -2
  97. package/dist/test-utils/cache.js +2 -2
  98. package/dist/test-utils/{fields-service.d.ts → services/fields-service.d.ts} +1 -1
  99. package/dist/test-utils/{fields-service.js → services/fields-service.js} +3 -2
  100. package/dist/test-utils/services/files-service.d.ts +28 -0
  101. package/dist/test-utils/services/files-service.js +34 -0
  102. package/dist/test-utils/services/folders-service.d.ts +28 -0
  103. package/dist/test-utils/services/folders-service.js +33 -0
  104. package/dist/utils/encrypt.d.ts +2 -0
  105. package/dist/utils/encrypt.js +64 -0
  106. package/dist/utils/get-accountability-for-role.js +2 -2
  107. package/dist/utils/get-accountability-for-token.js +4 -4
  108. package/dist/utils/get-cache-key.js +2 -2
  109. package/dist/utils/require-text.d.ts +1 -0
  110. package/dist/utils/require-text.js +4 -0
  111. package/dist/utils/require-yaml.js +2 -2
  112. package/package.json +32 -26
  113. package/dist/extensions/lib/sync-extensions.d.ts +0 -3
  114. package/dist/extensions/lib/sync-extensions.js +0 -70
  115. package/dist/extensions/lib/sync-status.d.ts +0 -10
  116. package/dist/extensions/lib/sync-status.js +0 -27
  117. package/dist/mcp/tools/index.d.ts +0 -15
  118. package/dist/mcp/tools/index.js +0 -29
  119. package/dist/mcp/tools/prompts/index.d.ts +0 -16
  120. package/dist/mcp/tools/prompts/index.js +0 -19
  121. package/dist/permissions/modules/fetch-global-access/lib/fetch-global-access-for-roles.d.ts +0 -5
  122. package/dist/permissions/modules/fetch-global-access/lib/fetch-global-access-for-roles.js +0 -7
  123. package/dist/permissions/modules/fetch-global-access/lib/fetch-global-access-for-user.d.ts +0 -5
  124. package/dist/permissions/modules/fetch-global-access/lib/fetch-global-access-for-user.js +0 -10
  125. package/dist/permissions/modules/fetch-global-access/types.d.ts +0 -4
  126. package/dist/permissions/modules/fetch-global-access/utils/fetch-global-access-for-query.d.ts +0 -4
  127. package/dist/permissions/modules/fetch-global-access/utils/fetch-global-access-for-query.js +0 -27
  128. package/dist/utils/get-date-formatted.d.ts +0 -1
  129. package/dist/utils/get-date-formatted.js +0 -10
  130. package/dist/utils/ip-in-networks.d.ts +0 -6
  131. package/dist/utils/ip-in-networks.js +0 -13
  132. /package/dist/{mcp → ai/mcp}/index.d.ts +0 -0
  133. /package/dist/{mcp → ai/mcp}/index.js +0 -0
  134. /package/dist/{mcp → ai/mcp}/transport.d.ts +0 -0
  135. /package/dist/{mcp → ai/mcp}/transport.js +0 -0
  136. /package/dist/{mcp → ai/mcp}/types.js +0 -0
  137. /package/dist/{mcp/tools/assets.d.ts → ai/tools/assets/index.d.ts} +0 -0
  138. /package/dist/{mcp/tools/prompts/assets.md → ai/tools/assets/prompt.md} +0 -0
  139. /package/dist/{mcp/tools/collections.d.ts → ai/tools/collections/index.d.ts} +0 -0
  140. /package/dist/{mcp/tools/prompts/collections.md → ai/tools/collections/prompt.md} +0 -0
  141. /package/dist/{mcp/define.d.ts → ai/tools/define-tool.d.ts} +0 -0
  142. /package/dist/{mcp/define.js → ai/tools/define-tool.js} +0 -0
  143. /package/dist/{mcp/tools/fields.d.ts → ai/tools/fields/index.d.ts} +0 -0
  144. /package/dist/{mcp/tools/prompts/fields.md → ai/tools/fields/prompt.md} +0 -0
  145. /package/dist/{mcp/tools/files.d.ts → ai/tools/files/index.d.ts} +0 -0
  146. /package/dist/{mcp/tools/prompts/files.md → ai/tools/files/prompt.md} +0 -0
  147. /package/dist/{mcp/tools/flows.d.ts → ai/tools/flows/index.d.ts} +0 -0
  148. /package/dist/{mcp/tools/prompts/flows.md → ai/tools/flows/prompt.md} +0 -0
  149. /package/dist/{mcp/tools/folders.d.ts → ai/tools/folders/index.d.ts} +0 -0
  150. /package/dist/{mcp/tools/prompts/folders.md → ai/tools/folders/prompt.md} +0 -0
  151. /package/dist/{mcp/tools/items.d.ts → ai/tools/items/index.d.ts} +0 -0
  152. /package/dist/{mcp/tools/prompts/operations.md → ai/tools/operations/prompt.md} +0 -0
  153. /package/dist/{mcp/tools/relations.d.ts → ai/tools/relations/index.d.ts} +0 -0
  154. /package/dist/{mcp/tools/prompts/relations.md → ai/tools/relations/prompt.md} +0 -0
  155. /package/dist/{mcp/tools/prompts/schema.md → ai/tools/schema/prompt.md} +0 -0
  156. /package/dist/{mcp → ai/tools}/schema.d.ts +0 -0
  157. /package/dist/{mcp → ai/tools}/schema.js +0 -0
  158. /package/dist/{mcp/tools/system.d.ts → ai/tools/system/index.d.ts} +0 -0
  159. /package/dist/{mcp/tools/prompts/system-prompt-description.md → ai/tools/system/prompt-description.md} +0 -0
  160. /package/dist/{mcp/tools/prompts/system-prompt.md → ai/tools/system/prompt.md} +0 -0
  161. /package/dist/{mcp/tools/trigger-flow.d.ts → ai/tools/trigger-flow/index.d.ts} +0 -0
  162. /package/dist/{mcp/tools/prompts/trigger-flow.md → ai/tools/trigger-flow/prompt.md} +0 -0
  163. /package/dist/{permissions/modules/fetch-global-access → ai/tools}/types.js +0 -0
  164. /package/dist/test-utils/{items-service.d.ts → services/items-service.d.ts} +0 -0
  165. /package/dist/test-utils/{items-service.js → services/items-service.js} +0 -0
@@ -1,4 +1,4 @@
1
- import type { Accountability, Query, SchemaOverview } from '@directus/types';
1
+ import type { Accountability, SchemaOverview } from '@directus/types';
2
2
  import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
3
3
  import type { ZodType } from 'zod';
4
4
  export type ToolResultBase = {
@@ -18,7 +18,6 @@ export type ToolResult = TextToolResult | AssetToolResult;
18
18
  export type ToolHandler<T> = {
19
19
  (options: {
20
20
  args: T;
21
- sanitizedQuery: Query;
22
21
  schema: SchemaOverview;
23
22
  accountability: Accountability | undefined;
24
23
  }): Promise<ToolResult | undefined>;
@@ -39,18 +38,3 @@ export interface ToolConfig<T> {
39
38
  annotations?: ToolAnnotations;
40
39
  handler: ToolHandler<T>;
41
40
  }
42
- export interface Prompt {
43
- name: string;
44
- system_prompt?: string | null;
45
- description?: string;
46
- messages: {
47
- role: 'user' | 'assistant';
48
- text: string;
49
- }[];
50
- }
51
- export interface MCPOptions {
52
- promptsCollection?: string;
53
- allowDeletes?: boolean;
54
- systemPromptEnabled?: boolean;
55
- systemPrompt?: string | null;
56
- }
@@ -0,0 +1,9 @@
1
+ import type { Accountability, SchemaOverview } from '@directus/types';
2
+ /**
3
+ * Build a sanitized query object from a tool's args payload.
4
+ * - Ensures fields defaults to '*' when not provided
5
+ * - Returns an empty object when no args.query is present
6
+ */
7
+ export declare function buildSanitizedQueryFromArgs<T extends {
8
+ query?: Record<string, any> | undefined;
9
+ }>(args: T, schema: SchemaOverview, accountability?: Accountability | null): Promise<Record<string, any>>;
@@ -0,0 +1,17 @@
1
+ import { sanitizeQuery } from '../../utils/sanitize-query.js';
2
+ /**
3
+ * Build a sanitized query object from a tool's args payload.
4
+ * - Ensures fields defaults to '*' when not provided
5
+ * - Returns an empty object when no args.query is present
6
+ */
7
+ export async function buildSanitizedQueryFromArgs(args, schema, accountability) {
8
+ let sanitizedQuery = {};
9
+ if (args?.query) {
10
+ const q = args.query;
11
+ sanitizedQuery = await sanitizeQuery({
12
+ fields: q['fields'] ?? '*',
13
+ ...q,
14
+ }, schema, accountability ?? undefined);
15
+ }
16
+ return sanitizedQuery;
17
+ }
package/dist/app.js CHANGED
@@ -68,6 +68,7 @@ import projectSchedule from './schedules/project.js';
68
68
  import { getConfigFromEnv } from './utils/get-config-from-env.js';
69
69
  import { Url } from './utils/url.js';
70
70
  import { validateStorage } from './utils/validate-storage.js';
71
+ import { aiChatRouter } from './ai/chat/router.js';
71
72
  const require = createRequire(import.meta.url);
72
73
  export default async function createApp() {
73
74
  const env = useEnv();
@@ -84,6 +85,9 @@ export default async function createApp() {
84
85
  if (!env['SECRET']) {
85
86
  logger.warn(`"SECRET" env variable is missing. Using a random value instead. Tokens will not persist between restarts. This is not appropriate for production usage.`);
86
87
  }
88
+ if (typeof env['SECRET'] === 'string' && Buffer.byteLength(env['SECRET']) < 32) {
89
+ logger.warn('"SECRET" env variable is shorter than 32 bytes which is insecure. This is not appropriate for production usage.');
90
+ }
87
91
  if (!new Url(env['PUBLIC_URL']).isAbsolute()) {
88
92
  logger.warn('"PUBLIC_URL" should be a full URL');
89
93
  }
@@ -231,6 +235,7 @@ export default async function createApp() {
231
235
  if (toBoolean(env['MCP_ENABLED']) === true) {
232
236
  app.use('/mcp', mcpRouter);
233
237
  }
238
+ app.use('/ai/chat', aiChatRouter);
234
239
  if (env['METRICS_ENABLED'] === true) {
235
240
  app.use('/metrics', metricsRouter);
236
241
  }
@@ -12,9 +12,9 @@ import { respond } from '../../middleware/respond.js';
12
12
  import { AuthenticationService } from '../../services/authentication.js';
13
13
  import asyncHandler from '../../utils/async-handler.js';
14
14
  import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
15
- import { LocalAuthDriver } from './local.js';
16
- import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
17
15
  import { getSchema } from '../../utils/get-schema.js';
16
+ import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
17
+ import { LocalAuthDriver } from './local.js';
18
18
  // Register the samlify schema validator
19
19
  samlify.setSchemaValidator(validator);
20
20
  export class SAMLAuthDriver extends LocalAuthDriver {
@@ -117,6 +117,9 @@ export function createSAMLAuthRouter(providerName) {
117
117
  const logger = useLogger();
118
118
  const relayState = req.body?.RelayState;
119
119
  const authMode = (env[`AUTH_${providerName.toUpperCase()}_MODE`] ?? 'session');
120
+ if (relayState && isLoginRedirectAllowed(relayState, providerName) === false) {
121
+ throw new InvalidPayloadError({ reason: `URL "${relayState}" can't be used to redirect after login` });
122
+ }
120
123
  try {
121
124
  const { sp, idp } = getAuthProvider(providerName);
122
125
  const { extract } = await sp.parseLoginResponse(idp, 'post', req);
@@ -1,10 +1,12 @@
1
1
  import { useEnv } from '@directus/env';
2
- import { InvalidQueryError, RangeNotSatisfiableError } from '@directus/errors';
2
+ import { InvalidPayloadError, InvalidQueryError, RangeNotSatisfiableError } from '@directus/errors';
3
3
  import { TransformationMethods } from '@directus/types';
4
- import { parseJSON } from '@directus/utils';
4
+ import { getDateTimeFormatted, parseJSON } from '@directus/utils';
5
5
  import contentDisposition from 'content-disposition';
6
6
  import { Router } from 'express';
7
7
  import { merge, pick } from 'lodash-es';
8
+ import * as z from 'zod';
9
+ import { fromZodError } from 'zod-validation-error';
8
10
  import { ASSET_TRANSFORM_QUERY_KEYS, SYSTEM_ASSET_ALLOW_LIST } from '../constants.js';
9
11
  import getDatabase from '../database/index.js';
10
12
  import { useLogger } from '../logger/index.js';
@@ -15,9 +17,44 @@ import asyncHandler from '../utils/async-handler.js';
15
17
  import { getCacheControlHeader } from '../utils/get-cache-headers.js';
16
18
  import { getConfigFromEnv } from '../utils/get-config-from-env.js';
17
19
  import { getMilliseconds } from '../utils/get-milliseconds.js';
20
+ import { isValidUuid } from '../utils/is-valid-uuid.js';
18
21
  const router = Router();
19
22
  const env = useEnv();
20
23
  router.use(useCollection('directus_files'));
24
+ router.post('/folder/:pk', asyncHandler(async (req, res) => {
25
+ const service = new AssetsService({
26
+ accountability: req.accountability,
27
+ schema: req.schema,
28
+ });
29
+ const { archive, complete, metadata } = await service.zipFolder(req.params['pk']);
30
+ res.setHeader('Content-Type', 'application/zip');
31
+ res.setHeader('Content-Disposition', `attachment; filename="folder-${metadata['name'] ? metadata['name'] : 'unknown'}-${getDateTimeFormatted()}.zip"`);
32
+ archive.pipe(res);
33
+ await complete();
34
+ }));
35
+ router.post('/files/', asyncHandler(async (req, res) => {
36
+ const service = new AssetsService({
37
+ accountability: req.accountability,
38
+ schema: req.schema,
39
+ });
40
+ const { error, data } = z
41
+ .object({
42
+ ids: z
43
+ .array(z.string().refine((v) => isValidUuid(v), {
44
+ error: '"id" must be a uuid',
45
+ }))
46
+ .min(1),
47
+ })
48
+ .safeParse(req.body);
49
+ if (error) {
50
+ throw new InvalidPayloadError({ reason: fromZodError(error).message });
51
+ }
52
+ const { archive, complete } = await service.zipFiles(data.ids);
53
+ res.setHeader('Content-Type', 'application/zip');
54
+ res.setHeader('Content-Disposition', `attachment; filename="files-${getDateTimeFormatted()}.zip"`);
55
+ archive.pipe(res);
56
+ await complete();
57
+ }));
21
58
  router.get('/:pk/:filename?',
22
59
  // Validate query params
23
60
  asyncHandler(async (req, res, next) => {
@@ -1,6 +1,6 @@
1
1
  import { ForbiddenError } from '@directus/errors';
2
2
  import { Router } from 'express';
3
- import { DirectusMCP } from '../mcp/index.js';
3
+ import { DirectusMCP } from '../ai/mcp/index.js';
4
4
  import { SettingsService } from '../services/settings.js';
5
5
  import asyncHandler from '../utils/async-handler.js';
6
6
  const router = Router();
@@ -6,8 +6,8 @@ import { fetchPermissions } from '../../permissions/lib/fetch-permissions.js';
6
6
  import { fetchPolicies } from '../../permissions/lib/fetch-policies.js';
7
7
  import { fetchRolesTree } from '../../permissions/lib/fetch-roles-tree.js';
8
8
  import { getSchema } from '../../utils/get-schema.js';
9
- import { getSchemaInspector } from '../index.js';
10
9
  import { mergePermissions } from '../../permissions/utils/merge-permissions.js';
10
+ import { getSchemaInspector } from '../index.js';
11
11
  async function fetchRoleAccess(roles, context) {
12
12
  const roleAccess = {
13
13
  admin_access: false,
@@ -184,7 +184,7 @@ export async function down(knex) {
184
184
  // role permissions to be inserted once all processing is completed
185
185
  const rolePermissions = [];
186
186
  for (const role of roles) {
187
- const roleTree = await fetchRolesTree(role.id, knex);
187
+ const roleTree = await fetchRolesTree(role.id, { knex });
188
188
  let roleAccess = null;
189
189
  if (role.id !== null) {
190
190
  roleAccess = await fetchRoleAccess(roleTree, context);
@@ -0,0 +1,3 @@
1
+ import type { Knex } from 'knex';
2
+ export declare function up(knex: Knex): Promise<void>;
3
+ export declare function down(knex: Knex): Promise<void>;
@@ -0,0 +1,14 @@
1
+ export async function up(knex) {
2
+ await knex.schema.alterTable('directus_settings', (table) => {
3
+ table.text('ai_openai_api_key');
4
+ table.text('ai_anthropic_api_key');
5
+ table.text('ai_system_prompt');
6
+ });
7
+ }
8
+ export async function down(knex) {
9
+ await knex.schema.alterTable('directus_settings', (table) => {
10
+ table.dropColumn('ai_openai_api_key');
11
+ table.dropColumn('ai_anthropic_api_key');
12
+ table.dropColumn('ai_system_prompt');
13
+ });
14
+ }
@@ -48,7 +48,7 @@ export async function runAst(originalAST, schema, accountability, options) {
48
48
  if (!rawItems)
49
49
  return null;
50
50
  // Run the items through the special transforms
51
- const payloadService = new PayloadService(collection, { knex, schema });
51
+ const payloadService = new PayloadService(collection, { accountability, knex, schema });
52
52
  let items = await payloadService.processValues('read', rawItems, query.alias ?? {}, query.aggregate ?? {});
53
53
  if (!items || (Array.isArray(items) && items.length === 0))
54
54
  return items;
@@ -63,11 +63,9 @@ export class InstallationManager {
63
63
  }
64
64
  await queue.onIdle();
65
65
  }
66
- else {
67
- // No custom location, so save to regular local extensions folder
68
- const dest = join(this.extensionPath, '.registry', versionId);
69
- await move(join(tempDir, extractedPath), dest, { overwrite: true });
70
- }
66
+ // move to regular local extensions folder
67
+ const dest = join(this.extensionPath, '.registry', versionId);
68
+ await move(join(tempDir, extractedPath), dest, { overwrite: true });
71
69
  }
72
70
  catch (err) {
73
71
  logger.warn(err);
@@ -92,9 +90,7 @@ export class InstallationManager {
92
90
  }
93
91
  await queue.onIdle();
94
92
  }
95
- else {
96
- const path = join(this.extensionPath, '.registry', folder);
97
- await remove(path);
98
- }
93
+ const path = join(this.extensionPath, '.registry', folder);
94
+ await remove(path);
99
95
  }
100
96
  }
@@ -0,0 +1,11 @@
1
+ export declare const SyncStatus: {
2
+ readonly SYNCING: "SYNCING";
3
+ readonly IDLE: "IDLE";
4
+ };
5
+ export type SyncStatus = keyof typeof SyncStatus;
6
+ export declare function getSyncStatus(): Promise<SyncStatus>;
7
+ export declare function setSyncStatus(status: SyncStatus): Promise<void>;
8
+ /**
9
+ * Checks the filesystem lock file if we are currently synchronizing
10
+ */
11
+ export declare function isSynchronizing(): Promise<boolean>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Utility functions to write a `.status` file on the filesystem to indicate active synchronization
3
+ */
4
+ import { join } from 'node:path';
5
+ import { exists } from 'fs-extra';
6
+ import { writeFile, rm } from 'node:fs/promises';
7
+ import { getExtensionsPath } from '../get-extensions-path.js';
8
+ export const SyncStatus = {
9
+ SYNCING: 'SYNCING',
10
+ IDLE: 'IDLE',
11
+ };
12
+ export async function getSyncStatus() {
13
+ const statusFilePath = join(getExtensionsPath(), '.status');
14
+ if (await exists(statusFilePath)) {
15
+ return SyncStatus.SYNCING;
16
+ }
17
+ return SyncStatus.IDLE;
18
+ }
19
+ export async function setSyncStatus(status) {
20
+ const statusFilePath = join(getExtensionsPath(), '.status');
21
+ if (status === SyncStatus.SYNCING) {
22
+ await writeFile(statusFilePath, '');
23
+ }
24
+ else {
25
+ await rm(statusFilePath);
26
+ }
27
+ }
28
+ /**
29
+ * Checks the filesystem lock file if we are currently synchronizing
30
+ */
31
+ export async function isSynchronizing() {
32
+ const status = await getSyncStatus();
33
+ return status === SyncStatus.SYNCING;
34
+ }
@@ -0,0 +1,6 @@
1
+ export type ExtensionSyncOptions = {
2
+ forceSync?: boolean;
3
+ partialSync?: string;
4
+ skipSync?: boolean;
5
+ };
6
+ export declare function syncExtensions(options?: ExtensionSyncOptions): Promise<void>;
@@ -0,0 +1,90 @@
1
+ import Queue from 'p-queue';
2
+ import mid from 'node-machine-id';
3
+ import { useEnv } from '@directus/env';
4
+ import { createWriteStream } from 'node:fs';
5
+ import { mkdir, rm } from 'node:fs/promises';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { dirname, join, relative, resolve, sep } from 'node:path';
8
+ import { getSyncPaths, compareFileMetadata } from './utils.js';
9
+ import { useBus } from '../../../bus/index.js';
10
+ import { SyncFileTracker } from './tracker.js';
11
+ import { useLock } from '../../../lock/index.js';
12
+ import { useLogger } from '../../../logger/index.js';
13
+ import { getStorage } from '../../../storage/index.js';
14
+ import { getExtensionsPath } from '../get-extensions-path.js';
15
+ import { isSynchronizing, setSyncStatus, SyncStatus } from './status.js';
16
+ import { normalizePath } from '@directus/utils';
17
+ export async function syncExtensions(options) {
18
+ if (options?.skipSync === true)
19
+ return;
20
+ const env = useEnv();
21
+ const lock = useLock();
22
+ const messenger = useBus();
23
+ const logger = useLogger();
24
+ if (options?.forceSync !== true && (await isSynchronizing())) {
25
+ logger.debug('Extensions are already being synced to this directory from another process.');
26
+ return;
27
+ }
28
+ const machineId = await mid.machineId();
29
+ const machineKey = `extensions-sync/${machineId}`;
30
+ const processId = await lock.increment(machineKey);
31
+ if (processId !== 1) {
32
+ logger.debug('Extensions are already being synced to this machine from another process.');
33
+ // Wait until the process that called the lock publishes a message that the syncing is complete
34
+ return new Promise((resolve) => {
35
+ messenger.subscribe(machineKey, () => resolve());
36
+ });
37
+ }
38
+ try {
39
+ logger.debug('Syncing extensions from configured storage location...');
40
+ // Ensure that the local extensions cache path exists
41
+ await mkdir(getExtensionsPath(), { recursive: true });
42
+ await setSyncStatus(SyncStatus.SYNCING);
43
+ const { localExtensionsPath, remoteExtensionsPath } = getSyncPaths(options?.partialSync);
44
+ const storage = await getStorage();
45
+ const disk = storage.location(env['EXTENSIONS_LOCATION']);
46
+ // check if we are only removing the local directory
47
+ if (options?.partialSync) {
48
+ const remoteExists = await disk.exists(normalizePath(join(remoteExtensionsPath, 'package.json')));
49
+ if (remoteExists === false) {
50
+ await rm(localExtensionsPath, { recursive: true, force: true });
51
+ return;
52
+ }
53
+ }
54
+ // Make sure we don't overload the file handles
55
+ const queue = new Queue({ concurrency: 1000 });
56
+ // start file tracker
57
+ const fileTracker = new SyncFileTracker();
58
+ const localFileCount = await fileTracker.readLocalFiles(localExtensionsPath);
59
+ const hasLocalFiles = localFileCount > 0;
60
+ for await (const filepath of disk.list(remoteExtensionsPath)) {
61
+ // We want files to be stored in the root of `$TEMP_PATH/extensions`, so gotta remove the
62
+ // extensions path on disk from the start of the file path
63
+ const relativePath = relative(resolve(sep, remoteExtensionsPath), resolve(sep, filepath));
64
+ const destinationPath = join(localExtensionsPath, relativePath);
65
+ await fileTracker.passedFile(relativePath);
66
+ // No need to check metadata when force is enabled
67
+ if (options?.forceSync !== true && hasLocalFiles) {
68
+ const fileUnchanged = await compareFileMetadata(destinationPath, filepath, disk);
69
+ if (fileUnchanged)
70
+ continue;
71
+ }
72
+ // Ensure that the directory path exists
73
+ await mkdir(dirname(destinationPath), { recursive: true });
74
+ // write remote file to the local filesystem
75
+ const readStream = await disk.read(filepath);
76
+ const writeStream = createWriteStream(destinationPath);
77
+ queue.add(() => pipeline(readStream, writeStream));
78
+ }
79
+ // wait for the queue to finish
80
+ await queue.onIdle();
81
+ // cleanup dangling local files
82
+ await fileTracker.cleanup(localExtensionsPath);
83
+ }
84
+ finally {
85
+ // release various locking mechanisms
86
+ messenger.publish(machineKey, { ready: true });
87
+ await lock.delete(machineKey);
88
+ await setSyncStatus(SyncStatus.IDLE);
89
+ }
90
+ }
@@ -0,0 +1,18 @@
1
+ export declare class SyncFileTracker {
2
+ private localFiles;
3
+ private trackedDirs;
4
+ constructor();
5
+ /**
6
+ * Reads all files recusrively in the provided directory
7
+ * @returns the number of files read
8
+ */
9
+ readLocalFiles(localExtensionsPath: string): Promise<number>;
10
+ /**
11
+ * Removes a file from the locally tracked files
12
+ */
13
+ passedFile(filePath: string): Promise<void>;
14
+ /**
15
+ * Removes left over tracked files that were not processed
16
+ */
17
+ cleanup(localExtensionsPath: string): Promise<void>;
18
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * class to help tracking file status between local and remote
3
+ */
4
+ import { readdir, rm } from 'node:fs/promises';
5
+ import { dirname, join, relative } from 'node:path';
6
+ import { pathDepth } from './utils.js';
7
+ export class SyncFileTracker {
8
+ localFiles;
9
+ trackedDirs;
10
+ constructor() {
11
+ this.localFiles = new Set();
12
+ this.trackedDirs = new Set();
13
+ }
14
+ /**
15
+ * Reads all files recusrively in the provided directory
16
+ * @returns the number of files read
17
+ */
18
+ async readLocalFiles(localExtensionsPath) {
19
+ const entries = await readdir(localExtensionsPath, { recursive: true, withFileTypes: true }).catch(() => {
20
+ /* path doesnt exist */
21
+ });
22
+ if (!entries)
23
+ return 0;
24
+ for (const entry of entries) {
25
+ if (!entry.isFile())
26
+ continue;
27
+ const relativePath = join(relative(localExtensionsPath, entry.parentPath), entry.name);
28
+ this.localFiles.add(relativePath);
29
+ }
30
+ return this.localFiles.size;
31
+ }
32
+ /**
33
+ * Removes a file from the locally tracked files
34
+ */
35
+ async passedFile(filePath) {
36
+ this.localFiles.delete(filePath);
37
+ let currentDir = dirname(filePath);
38
+ while (currentDir !== '.') {
39
+ if (this.trackedDirs.has(currentDir))
40
+ break;
41
+ this.trackedDirs.add(currentDir);
42
+ currentDir = dirname(currentDir);
43
+ }
44
+ }
45
+ /**
46
+ * Removes left over tracked files that were not processed
47
+ */
48
+ async cleanup(localExtensionsPath) {
49
+ const removeDirs = new Set();
50
+ for (const removeFile of this.localFiles) {
51
+ if (removeFile === '.status')
52
+ continue;
53
+ let currentDir = dirname(removeFile);
54
+ while (currentDir !== localExtensionsPath && currentDir !== '.') {
55
+ if (this.trackedDirs.has(currentDir))
56
+ break;
57
+ removeDirs.add(currentDir);
58
+ currentDir = dirname(currentDir);
59
+ }
60
+ }
61
+ // sort directory by depth so we can remove the highest level directory recursively
62
+ const removeDirsRecursive = Array.from(removeDirs)
63
+ .sort((a, b) => pathDepth(b) - pathDepth(a))
64
+ .filter((d) => !removeDirs.has(dirname(d)));
65
+ for (const dir of removeDirsRecursive) {
66
+ const relativePath = join(localExtensionsPath, dir);
67
+ // removing local folder that does not exist in the remote storage
68
+ await rm(relativePath, { recursive: true, force: true });
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,24 @@
1
+ import type { Driver } from '@directus/storage';
2
+ /**
3
+ * Returns the directory depth of the provided path
4
+ */
5
+ export declare function pathDepth(path: string): number;
6
+ /**
7
+ * Reads the size and modified date of a file if it exists
8
+ */
9
+ export declare function fsStat(path: string): Promise<{
10
+ size: number;
11
+ modified: Date;
12
+ } | null>;
13
+ /**
14
+ * Builds up the local and remote paths to use with syncing
15
+ */
16
+ export declare function getSyncPaths(partialPath: string | undefined): {
17
+ localExtensionsPath: string;
18
+ remoteExtensionsPath: string;
19
+ };
20
+ /**
21
+ * Retrieve the stats for local and remote files and check if they are the same
22
+ * Returns false if files are differnt else true
23
+ */
24
+ export declare function compareFileMetadata(localPath: string, remotePath: string, disk: Driver): Promise<boolean>;
@@ -0,0 +1,62 @@
1
+ import { join, relative, resolve, sep } from 'node:path';
2
+ import { stat } from 'node:fs/promises';
3
+ import { getExtensionsPath } from '../get-extensions-path.js';
4
+ import { useEnv } from '@directus/env';
5
+ import { normalizePath } from '@directus/utils';
6
+ /**
7
+ * Returns the directory depth of the provided path
8
+ */
9
+ export function pathDepth(path) {
10
+ let count = 0;
11
+ for (let i = 0; i < path.length; i++) {
12
+ if (path[i] === sep)
13
+ count++;
14
+ }
15
+ return count;
16
+ }
17
+ /**
18
+ * Reads the size and modified date of a file if it exists
19
+ */
20
+ export async function fsStat(path) {
21
+ const data = await stat(path, { bigint: false }).catch(() => {
22
+ /* file not available */
23
+ });
24
+ if (!data)
25
+ return null;
26
+ return {
27
+ size: data.size,
28
+ modified: data.mtime,
29
+ };
30
+ }
31
+ /**
32
+ * Builds up the local and remote paths to use with syncing
33
+ */
34
+ export function getSyncPaths(partialPath) {
35
+ const env = useEnv();
36
+ const localRootPath = getExtensionsPath();
37
+ const remoteRootPath = env['EXTENSIONS_PATH'];
38
+ if (!partialPath) {
39
+ return {
40
+ localExtensionsPath: localRootPath,
41
+ remoteExtensionsPath: normalizePath(remoteRootPath),
42
+ };
43
+ }
44
+ const resolvedPartialPath = relative(sep, resolve(sep, partialPath));
45
+ return {
46
+ localExtensionsPath: join(localRootPath, resolvedPartialPath),
47
+ remoteExtensionsPath: normalizePath(join(remoteRootPath, resolvedPartialPath)),
48
+ };
49
+ }
50
+ /**
51
+ * Retrieve the stats for local and remote files and check if they are the same
52
+ * Returns false if files are differnt else true
53
+ */
54
+ export async function compareFileMetadata(localPath, remotePath, disk) {
55
+ const localStat = await fsStat(localPath).catch(() => { });
56
+ if (!localStat)
57
+ return false;
58
+ const remoteStat = await disk.stat(remotePath).catch(() => { });
59
+ if (!remoteStat)
60
+ return false;
61
+ return remoteStat.modified <= localStat.modified && remoteStat.size === localStat.size;
62
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Extension, ExtensionManagerOptions } from '@directus/types';
2
2
  import { Router } from 'express';
3
3
  import type { ReadStream } from 'node:fs';
4
+ import { type ExtensionSyncOptions } from './lib/sync/sync.js';
4
5
  export declare class ExtensionManager {
5
6
  private options;
6
7
  /**
@@ -47,6 +48,10 @@ export declare class ExtensionManager {
47
48
  * sequence.
48
49
  */
49
50
  private reloadQueue;
51
+ /**
52
+ * Used to prevent race condition when reading extension data while reloading extensions
53
+ */
54
+ private reloadPromise;
50
55
  /**
51
56
  * Optional file system watcher to auto-reload extensions when the local file system changes
52
57
  */
@@ -76,7 +81,7 @@ export declare class ExtensionManager {
76
81
  */
77
82
  install(versionId: string): Promise<void>;
78
83
  uninstall(folder: string): Promise<void>;
79
- broadcastReloadNotification(): Promise<void>;
84
+ broadcastReloadNotification(options?: ExtensionSyncOptions): Promise<void>;
80
85
  /**
81
86
  * Load all extensions from disk and register them in their respective places
82
87
  */
@@ -88,9 +93,8 @@ export declare class ExtensionManager {
88
93
  /**
89
94
  * Reload all the extensions. Will unload if extensions have already been loaded
90
95
  */
91
- reload(options?: {
92
- forceSync: boolean;
93
- }): Promise<unknown>;
96
+ reload(options?: ExtensionSyncOptions): Promise<unknown>;
97
+ isReloading(): Promise<void>;
94
98
  /**
95
99
  * Return the previously generated app extension bundle chunk by name.
96
100
  * Providing no name will return the entry bundle.