@directus/api 33.3.1 → 34.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 (98) hide show
  1. package/dist/ai/chat/lib/create-ui-stream.js +2 -1
  2. package/dist/ai/chat/lib/transform-file-parts.d.ts +12 -0
  3. package/dist/ai/chat/lib/transform-file-parts.js +36 -0
  4. package/dist/ai/files/adapters/anthropic.d.ts +3 -0
  5. package/dist/ai/files/adapters/anthropic.js +25 -0
  6. package/dist/ai/files/adapters/google.d.ts +3 -0
  7. package/dist/ai/files/adapters/google.js +58 -0
  8. package/dist/ai/files/adapters/index.d.ts +3 -0
  9. package/dist/ai/files/adapters/index.js +3 -0
  10. package/dist/ai/files/adapters/openai.d.ts +3 -0
  11. package/dist/ai/files/adapters/openai.js +22 -0
  12. package/dist/ai/files/controllers/upload.d.ts +2 -0
  13. package/dist/ai/files/controllers/upload.js +101 -0
  14. package/dist/ai/files/lib/fetch-provider.d.ts +1 -0
  15. package/dist/ai/files/lib/fetch-provider.js +23 -0
  16. package/dist/ai/files/lib/upload-to-provider.d.ts +4 -0
  17. package/dist/ai/files/lib/upload-to-provider.js +26 -0
  18. package/dist/ai/files/router.d.ts +1 -0
  19. package/dist/ai/files/router.js +5 -0
  20. package/dist/ai/files/types.d.ts +5 -0
  21. package/dist/ai/files/types.js +1 -0
  22. package/dist/ai/providers/anthropic-file-support.d.ts +12 -0
  23. package/dist/ai/providers/anthropic-file-support.js +94 -0
  24. package/dist/ai/providers/registry.js +3 -6
  25. package/dist/ai/tools/flows/index.d.ts +16 -16
  26. package/dist/ai/tools/schema.d.ts +8 -8
  27. package/dist/ai/tools/schema.js +2 -2
  28. package/dist/app.js +10 -1
  29. package/dist/controllers/deployment-webhooks.d.ts +2 -0
  30. package/dist/controllers/deployment-webhooks.js +95 -0
  31. package/dist/controllers/deployment.js +61 -165
  32. package/dist/controllers/files.js +2 -1
  33. package/dist/database/get-ast-from-query/lib/parse-fields.js +52 -26
  34. package/dist/database/helpers/date/dialects/oracle.js +2 -0
  35. package/dist/database/helpers/date/dialects/sqlite.js +2 -0
  36. package/dist/database/helpers/date/types.d.ts +1 -1
  37. package/dist/database/helpers/date/types.js +3 -1
  38. package/dist/database/helpers/fn/dialects/mssql.d.ts +1 -0
  39. package/dist/database/helpers/fn/dialects/mssql.js +21 -0
  40. package/dist/database/helpers/fn/dialects/mysql.d.ts +2 -0
  41. package/dist/database/helpers/fn/dialects/mysql.js +30 -0
  42. package/dist/database/helpers/fn/dialects/oracle.d.ts +1 -0
  43. package/dist/database/helpers/fn/dialects/oracle.js +21 -0
  44. package/dist/database/helpers/fn/dialects/postgres.d.ts +14 -0
  45. package/dist/database/helpers/fn/dialects/postgres.js +40 -0
  46. package/dist/database/helpers/fn/dialects/sqlite.d.ts +1 -0
  47. package/dist/database/helpers/fn/dialects/sqlite.js +12 -0
  48. package/dist/database/helpers/fn/json/parse-function.d.ts +19 -0
  49. package/dist/database/helpers/fn/json/parse-function.js +66 -0
  50. package/dist/database/helpers/fn/types.d.ts +8 -0
  51. package/dist/database/helpers/fn/types.js +19 -0
  52. package/dist/database/helpers/schema/dialects/mysql.d.ts +1 -0
  53. package/dist/database/helpers/schema/dialects/mysql.js +11 -0
  54. package/dist/database/helpers/schema/types.d.ts +1 -0
  55. package/dist/database/helpers/schema/types.js +3 -0
  56. package/dist/database/index.js +2 -1
  57. package/dist/database/migrations/20260211A-add-deployment-webhooks.d.ts +3 -0
  58. package/dist/database/migrations/20260211A-add-deployment-webhooks.js +37 -0
  59. package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
  60. package/dist/database/run-ast/lib/apply-query/filter/operator.js +17 -7
  61. package/dist/database/run-ast/lib/parse-current-level.js +8 -1
  62. package/dist/database/run-ast/run-ast.js +11 -1
  63. package/dist/database/run-ast/utils/apply-function-to-column-name.js +7 -1
  64. package/dist/database/run-ast/utils/get-column.js +13 -2
  65. package/dist/deployment/deployment.d.ts +25 -2
  66. package/dist/deployment/drivers/netlify.d.ts +6 -2
  67. package/dist/deployment/drivers/netlify.js +114 -12
  68. package/dist/deployment/drivers/vercel.d.ts +5 -2
  69. package/dist/deployment/drivers/vercel.js +84 -5
  70. package/dist/deployment.d.ts +5 -0
  71. package/dist/deployment.js +34 -0
  72. package/dist/permissions/utils/get-unaliased-field-key.js +9 -1
  73. package/dist/request/is-denied-ip.js +24 -23
  74. package/dist/services/authentication.js +27 -22
  75. package/dist/services/collections.js +1 -0
  76. package/dist/services/deployment-projects.d.ts +31 -2
  77. package/dist/services/deployment-projects.js +109 -5
  78. package/dist/services/deployment-runs.d.ts +19 -1
  79. package/dist/services/deployment-runs.js +86 -0
  80. package/dist/services/deployment.d.ts +44 -3
  81. package/dist/services/deployment.js +263 -15
  82. package/dist/services/files/utils/get-metadata.js +6 -6
  83. package/dist/services/files.d.ts +3 -1
  84. package/dist/services/files.js +26 -3
  85. package/dist/services/graphql/resolvers/query.js +23 -6
  86. package/dist/services/payload.d.ts +6 -0
  87. package/dist/services/payload.js +27 -2
  88. package/dist/services/server.js +1 -1
  89. package/dist/services/users.js +6 -1
  90. package/dist/utils/get-field-relational-depth.d.ts +13 -0
  91. package/dist/utils/get-field-relational-depth.js +22 -0
  92. package/dist/utils/parse-value.d.ts +4 -0
  93. package/dist/utils/parse-value.js +11 -0
  94. package/dist/utils/sanitize-query.js +3 -2
  95. package/dist/utils/split-fields.d.ts +4 -0
  96. package/dist/utils/split-fields.js +32 -0
  97. package/dist/utils/validate-query.js +2 -1
  98. package/package.json +29 -29
@@ -1,6 +1,21 @@
1
+ import { InvalidQueryError } from '@directus/errors';
1
2
  import { getOutputTypeForFunction } from '@directus/utils';
2
3
  import { getHelpers } from '../../../../helpers/index.js';
3
4
  import { getColumn } from '../../../utils/get-column.js';
5
+ function castToNumber(value) {
6
+ if (Array.isArray(value)) {
7
+ return value.map((val) => {
8
+ const num = Number(val);
9
+ if (Number.isNaN(num))
10
+ throw new InvalidQueryError({ reason: `Invalid numeric value` });
11
+ return num;
12
+ });
13
+ }
14
+ const num = Number(value);
15
+ if (Number.isNaN(num))
16
+ throw new InvalidQueryError({ reason: `Invalid numeric value` });
17
+ return num;
18
+ }
4
19
  export function applyOperator(knex, dbQuery, schema, key, operator, compareValue, logical = 'and', originalCollectionName) {
5
20
  const helpers = getHelpers(knex);
6
21
  const [table, column] = key.split('.');
@@ -47,7 +62,7 @@ export function applyOperator(knex, dbQuery, schema, key, operator, compareValue
47
62
  const functionName = column.split('(')[0];
48
63
  const type = getOutputTypeForFunction(functionName);
49
64
  if (['integer', 'float', 'decimal'].includes(type)) {
50
- compareValue = Array.isArray(compareValue) ? compareValue.map(Number) : Number(compareValue);
65
+ compareValue = castToNumber(compareValue);
51
66
  }
52
67
  }
53
68
  // Cast filter value (compareValue) based on type of field being filtered against
@@ -64,12 +79,7 @@ export function applyOperator(knex, dbQuery, schema, key, operator, compareValue
64
79
  }
65
80
  }
66
81
  if (['integer', 'float', 'decimal'].includes(type)) {
67
- if (Array.isArray(compareValue)) {
68
- compareValue = compareValue.map((val) => Number(val));
69
- }
70
- else {
71
- compareValue = Number(compareValue);
72
- }
82
+ compareValue = castToNumber(compareValue);
73
83
  }
74
84
  }
75
85
  if (operator === '_eq') {
@@ -1,4 +1,5 @@
1
1
  import { parseFilterKey } from '../../../utils/parse-filter-key.js';
2
+ import { parseJsonFunction } from '../../helpers/fn/json/parse-function.js';
2
3
  export async function parseCurrentLevel(schema, collection, children, query) {
3
4
  const primaryKeyField = schema.collections[collection].primary;
4
5
  const columnsInCollection = Object.keys(schema.collections[collection].fields);
@@ -6,7 +7,13 @@ export async function parseCurrentLevel(schema, collection, children, query) {
6
7
  const nestedCollectionNodes = [];
7
8
  for (const child of children) {
8
9
  if (child.type === 'field' || child.type === 'functionField') {
9
- const { fieldName } = parseFilterKey(child.name);
10
+ let fieldName;
11
+ if (child.type == 'functionField' && child.name.startsWith('json')) {
12
+ fieldName = parseJsonFunction(child.name).field;
13
+ }
14
+ else {
15
+ fieldName = parseFilterKey(child.name).fieldName;
16
+ }
10
17
  if (columnsInCollection.includes(fieldName)) {
11
18
  columnsToSelectInternal.push(child.fieldKey);
12
19
  }
@@ -6,6 +6,7 @@ import { PayloadService } from '../../services/payload.js';
6
6
  import getDatabase from '../index.js';
7
7
  import { getDBQuery } from './lib/get-db-query.js';
8
8
  import { parseCurrentLevel } from './lib/parse-current-level.js';
9
+ import { applyFunctionToColumnName } from './utils/apply-function-to-column-name.js';
9
10
  import { applyParentFilters } from './utils/apply-parent-filters.js';
10
11
  import { mergeWithParentItems } from './utils/merge-with-parent-items.js';
11
12
  import { removeTemporaryFields } from './utils/remove-temporary-fields.js';
@@ -49,7 +50,16 @@ export async function runAst(originalAST, schema, accountability, options) {
49
50
  return null;
50
51
  // Run the items through the special transforms
51
52
  const payloadService = new PayloadService(collection, { accountability, knex, schema });
52
- let items = await payloadService.processValues('read', rawItems, query.alias ?? {}, query.aggregate ?? {});
53
+ // Build alias map that includes auto-generated json function field mappings
54
+ // so processJsonFunctionResults can detect and parse stringified JSON values
55
+ const aliasMap = { ...query.alias };
56
+ for (const child of children) {
57
+ if (child.type === 'functionField' && child.name.startsWith('json(')) {
58
+ const alias = applyFunctionToColumnName(child.fieldKey);
59
+ aliasMap[alias] = child.name;
60
+ }
61
+ }
62
+ let items = await payloadService.processValues('read', rawItems, aliasMap, query.aggregate ?? {});
53
63
  if (!items || (Array.isArray(items) && items.length === 0))
54
64
  return items;
55
65
  // Apply the `_in` filters to the nested collection batches
@@ -14,7 +14,13 @@ export function applyFunctionToColumnName(column) {
14
14
  if (column.includes('(') && column.includes(')')) {
15
15
  const functionName = column.split('(')[0];
16
16
  const columnName = column.match(REGEX_BETWEEN_PARENS)[1];
17
- return `${columnName}_${functionName}`;
17
+ if (functionName === 'json') {
18
+ const slug = columnName?.replace(/[.[\],\s]+/g, '_');
19
+ return `${slug}${slug?.endsWith('_') ? '' : '_'}${functionName}`;
20
+ }
21
+ else {
22
+ return `${columnName}_${functionName}`;
23
+ }
18
24
  }
19
25
  else {
20
26
  return column;
@@ -1,6 +1,7 @@
1
1
  import { REGEX_BETWEEN_PARENS } from '@directus/constants';
2
2
  import { InvalidQueryError } from '@directus/errors';
3
3
  import { getFunctionsForType } from '@directus/utils';
4
+ import { parseJsonFunction } from '../../helpers/fn/json/parse-function.js';
4
5
  import { getFunctions } from '../../helpers/index.js';
5
6
  import { applyFunctionToColumnName } from './apply-function-to-column-name.js';
6
7
  /**
@@ -22,12 +23,21 @@ export function getColumn(knex, table, column, alias = applyFunctionToColumnName
22
23
  const columnName = column.match(REGEX_BETWEEN_PARENS)[1];
23
24
  if (functionName in fn) {
24
25
  const collectionName = options?.originalCollectionName || table;
25
- const type = schema?.collections[collectionName]?.fields?.[columnName]?.type ?? 'unknown';
26
+ let fieldName = columnName;
27
+ let jsonPath;
28
+ // For json function, extract the base field name from the arguments
29
+ // json(metadata, color) -> metadata
30
+ if (functionName === 'json') {
31
+ const result = parseJsonFunction(column);
32
+ fieldName = result.field;
33
+ jsonPath = result.path;
34
+ }
35
+ const type = schema?.collections[collectionName]?.fields?.[fieldName]?.type ?? 'unknown';
26
36
  const allowedFunctions = getFunctionsForType(type);
27
37
  if (allowedFunctions.includes(functionName) === false) {
28
38
  throw new InvalidQueryError({ reason: `Invalid function specified "${functionName}"` });
29
39
  }
30
- const result = fn[functionName](table, columnName, {
40
+ const result = fn[functionName](table, fieldName, {
31
41
  type,
32
42
  relationalCountOptions: isFunctionColumnOptions(options)
33
43
  ? {
@@ -37,6 +47,7 @@ export function getColumn(knex, table, column, alias = applyFunctionToColumnName
37
47
  }
38
48
  : undefined,
39
49
  originalCollectionName: options?.originalCollectionName,
50
+ jsonPath,
40
51
  });
41
52
  if (alias) {
42
53
  return knex.raw(result + ' AS ??', [alias]);
@@ -1,4 +1,4 @@
1
- import type { Credentials, Deployment, Details, Log, Options, Project, TriggerResult } from '@directus/types';
1
+ import type { Credentials, Deployment, DeploymentWebhookEvent, Details, Log, Options, Project, Status, TriggerResult, WebhookRegistrationResult } from '@directus/types';
2
2
  import type { AxiosRequestConfig, AxiosResponse } from 'axios';
3
3
  export type DeploymentRequestOptions = Pick<AxiosRequestConfig<string>, 'method' | 'headers'> & {
4
4
  body?: string | null;
@@ -77,7 +77,7 @@ export declare abstract class DeploymentDriver<TCredentials extends Credentials
77
77
  * @throws {HitRateLimitError} When rate limit is exceeded
78
78
  * @throws {ServiceUnavailableError} When provider API fails
79
79
  */
80
- abstract cancelDeployment(deploymentId: string): Promise<void>;
80
+ abstract cancelDeployment(deploymentId: string): Promise<Status>;
81
81
  /**
82
82
  * Get deployment build logs
83
83
  *
@@ -91,4 +91,27 @@ export declare abstract class DeploymentDriver<TCredentials extends Credentials
91
91
  abstract getDeploymentLogs(deploymentId: string, options?: {
92
92
  since?: Date;
93
93
  }): Promise<Log[]>;
94
+ /**
95
+ * Register a webhook with the provider
96
+ *
97
+ * @param webhookUrl The public URL that will receive webhook events
98
+ * @param projectIds External project IDs to scope the webhook
99
+ * @returns Webhook ID and secret for signature verification
100
+ */
101
+ abstract registerWebhook(webhookUrl: string, projectIds: string[]): Promise<WebhookRegistrationResult>;
102
+ /**
103
+ * Unregister webhooks from the provider
104
+ *
105
+ * @param webhookIds The webhook IDs returned from registerWebhook
106
+ */
107
+ abstract unregisterWebhook(webhookIds: string[]): Promise<void>;
108
+ /**
109
+ * Verify webhook signature and parse the event payload
110
+ *
111
+ * @param rawBody Raw request body buffer (before JSON parsing)
112
+ * @param headers Request headers
113
+ * @param webhookSecret Secret used for signature verification
114
+ * @returns Parsed event or null if signature is invalid
115
+ */
116
+ abstract verifyAndParseWebhook(rawBody: Buffer, headers: Record<string, string | string[] | undefined>, webhookSecret: string): DeploymentWebhookEvent | null;
94
117
  }
@@ -1,4 +1,4 @@
1
- import type { Credentials, Deployment, Details, Log, Options, Project, TriggerResult } from '@directus/types';
1
+ import type { Credentials, Deployment, DeploymentWebhookEvent, Details, Log, Options, Project, Status, TriggerResult, WebhookRegistrationResult } from '@directus/types';
2
2
  import { DeploymentDriver } from '../deployment.js';
3
3
  export interface NetlifyCredentials extends Credentials {
4
4
  access_token: string;
@@ -22,11 +22,15 @@ export declare class NetlifyDriver extends DeploymentDriver<NetlifyCredentials,
22
22
  preview?: boolean;
23
23
  clearCache?: boolean;
24
24
  }): Promise<TriggerResult>;
25
- cancelDeployment(deploymentId: string): Promise<void>;
25
+ cancelDeployment(deploymentId: string): Promise<Status>;
26
26
  private closeWsConnection;
27
27
  private setupWsIdleTimeout;
28
28
  private setupWsConnectionTimeout;
29
29
  private getWsConnection;
30
+ registerWebhook(webhookUrl: string, projectIds: string[]): Promise<WebhookRegistrationResult>;
31
+ private cleanupStaleHooks;
32
+ unregisterWebhook(webhookIds: string[]): Promise<void>;
33
+ verifyAndParseWebhook(rawBody: Buffer, headers: Record<string, string | string[] | undefined>, webhookSecret: string): DeploymentWebhookEvent | null;
30
34
  getDeploymentLogs(deploymentId: string, options?: {
31
35
  since?: Date;
32
36
  }): Promise<Log[]>;
@@ -1,6 +1,8 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
1
2
  import { InvalidCredentialsError, ServiceUnavailableError } from '@directus/errors';
2
3
  import { NetlifyAPI } from '@netlify/api';
3
4
  import { isNumber } from 'lodash-es';
5
+ import { useLogger } from '../../logger/index.js';
4
6
  import { DeploymentDriver } from '../deployment.js';
5
7
  const WS_CONNECTIONS = new Map();
6
8
  const WS_IDLE_TIMEOUT = 60_000; // 60 seconds
@@ -8,6 +10,13 @@ const WS_CONNECTION_TIMEOUT = 10_000; // 10 seconds
8
10
  // eslint-disable-next-line no-control-regex
9
11
  const ANSI_REGEX = /[\x1b]\[[0-9;]*m/g;
10
12
  const WS_URL = 'wss://socketeer.services.netlify.com/build/logs';
13
+ const NETLIFY_WEBHOOK_EVENTS = ['deploy_created', 'deploy_building', 'deploy_failed', 'deploy_succeeded'];
14
+ // Map Netlify deploy state to our normalized types
15
+ const STATE_TO_EVENT = {
16
+ building: { type: 'deployment.created', status: 'building' },
17
+ ready: { type: 'deployment.succeeded', status: 'ready' },
18
+ error: { type: 'deployment.error', status: 'error' },
19
+ };
11
20
  export class NetlifyDriver extends DeploymentDriver {
12
21
  api;
13
22
  constructor(credentials, options = {}) {
@@ -63,16 +72,23 @@ export class NetlifyDriver extends DeploymentDriver {
63
72
  return result;
64
73
  }
65
74
  async listProjects() {
66
- const params = { per_page: '100' };
67
- const response = await this.handleApiError((api) => {
68
- return this.options.account_slug
69
- ? api.listSitesForAccount({
70
- account_slug: this.options.account_slug,
71
- ...params,
72
- })
73
- : api.listSites(params);
74
- });
75
- return response.map((site) => this.mapSiteBase(site));
75
+ const allSites = [];
76
+ const perPage = 100;
77
+ let hasMore = true;
78
+ for (let page = 1; hasMore; page++) {
79
+ const params = { per_page: String(perPage), page: String(page) };
80
+ const response = await this.handleApiError((api) => {
81
+ return this.options.account_slug
82
+ ? api.listSitesForAccount({
83
+ account_slug: this.options.account_slug,
84
+ ...params,
85
+ })
86
+ : api.listSites(params);
87
+ });
88
+ allSites.push(...response);
89
+ hasMore = response.length >= perPage;
90
+ }
91
+ return allSites.map((site) => this.mapSiteBase(site));
76
92
  }
77
93
  async getProject(projectId) {
78
94
  const site = await this.handleApiError((api) => api.getSite({ siteId: projectId }));
@@ -149,12 +165,27 @@ export class NetlifyDriver extends DeploymentDriver {
149
165
  const triggerResult = {
150
166
  deployment_id: buildResponse.deploy_id,
151
167
  status: this.mapStatus(deployState.state),
168
+ created_at: new Date(deployState.created_at),
152
169
  };
153
170
  return triggerResult;
154
171
  }
155
172
  async cancelDeployment(deploymentId) {
156
- await this.handleApiError((api) => api.cancelSiteDeploy({ deployId: deploymentId }));
157
- this.closeWsConnection(deploymentId);
173
+ try {
174
+ await this.handleApiError((api) => api.cancelSiteDeploy({ deployId: deploymentId }));
175
+ this.closeWsConnection(deploymentId);
176
+ return 'canceled';
177
+ }
178
+ catch {
179
+ const details = await this.getDeployment(deploymentId);
180
+ if (details.status !== 'building') {
181
+ this.closeWsConnection(deploymentId);
182
+ return details.status;
183
+ }
184
+ throw new ServiceUnavailableError({
185
+ service: 'netlify',
186
+ reason: `Could not cancel the deployment: ${deploymentId}`,
187
+ });
188
+ }
158
189
  }
159
190
  closeWsConnection(deploymentId, remove = true) {
160
191
  const connection = WS_CONNECTIONS.get(deploymentId);
@@ -245,6 +276,77 @@ export class NetlifyDriver extends DeploymentDriver {
245
276
  });
246
277
  });
247
278
  }
279
+ async registerWebhook(webhookUrl, projectIds) {
280
+ const logger = useLogger();
281
+ const secret = randomBytes(32).toString('hex');
282
+ const hookIds = [];
283
+ // Netlify API doesn't support JWS signing for API-created hooks,
284
+ // so we inject a token for verification instead
285
+ const signedUrl = `${webhookUrl}?token=${secret}`;
286
+ for (const siteId of projectIds) {
287
+ await this.cleanupStaleHooks(siteId, webhookUrl);
288
+ }
289
+ for (const siteId of projectIds) {
290
+ for (const event of NETLIFY_WEBHOOK_EVENTS) {
291
+ const hook = await this.handleApiError((api) => api.createHookBySiteId({
292
+ site_id: siteId,
293
+ body: { type: 'url', event, data: { url: signedUrl } },
294
+ }));
295
+ logger.debug(`[webhook:netlify] Created hook ${hook.id} for event ${event}`);
296
+ hookIds.push(hook.id);
297
+ }
298
+ }
299
+ return { webhook_ids: hookIds, webhook_secret: secret };
300
+ }
301
+ async cleanupStaleHooks(siteId, webhookUrl) {
302
+ const logger = useLogger();
303
+ const hooks = await this.handleApiError((api) => api.listHooksBySiteId({ site_id: siteId }));
304
+ const staleHooks = hooks.filter((h) => h.data?.url?.startsWith(webhookUrl));
305
+ if (staleHooks.length > 0) {
306
+ logger.debug(`[webhook:netlify] Cleaning up ${staleHooks.length} stale hook(s) for site ${siteId}`);
307
+ await Promise.allSettled(staleHooks.map((h) => this.api.deleteHook({ hook_id: h.id })));
308
+ }
309
+ }
310
+ async unregisterWebhook(webhookIds) {
311
+ await Promise.allSettled(webhookIds.map((id) => this.api.deleteHook({ hook_id: id })));
312
+ }
313
+ verifyAndParseWebhook(rawBody, headers, webhookSecret) {
314
+ const logger = useLogger();
315
+ // URL token verification — Netlify API doesn't support JWS signing for API-created hooks,
316
+ // so we embed a secret token in the webhook URL and verify it here.
317
+ // The token is passed via the 'x-webhook-token'
318
+ const token = headers['x-webhook-token'];
319
+ if (!token || typeof token !== 'string') {
320
+ logger.warn(`[webhook:netlify] Missing webhook token`);
321
+ return null;
322
+ }
323
+ const tokenBuf = Buffer.from(token);
324
+ const secretBuf = Buffer.from(webhookSecret);
325
+ if (tokenBuf.length !== secretBuf.length || !timingSafeEqual(tokenBuf, secretBuf)) {
326
+ logger.warn(`[webhook:netlify] Token mismatch`);
327
+ return null;
328
+ }
329
+ // Parse deploy object from body
330
+ const deploy = JSON.parse(rawBody.toString('utf-8'));
331
+ const state = this.mapStatus(deploy.state);
332
+ const mapping = STATE_TO_EVENT[state];
333
+ if (!mapping) {
334
+ return null;
335
+ }
336
+ const url = deploy.ssl_url || deploy.deploy_ssl_url || deploy.url;
337
+ const timestamp = deploy.published_at || deploy.updated_at || deploy.created_at;
338
+ return {
339
+ type: mapping.type,
340
+ provider: 'netlify',
341
+ project_external_id: deploy.site_id,
342
+ deployment_external_id: deploy.id,
343
+ status: mapping.status,
344
+ ...(url ? { url } : {}),
345
+ ...(deploy.context ? { target: deploy.context } : {}),
346
+ timestamp: new Date(timestamp),
347
+ raw: deploy,
348
+ };
349
+ }
248
350
  async getDeploymentLogs(deploymentId, options) {
249
351
  const deploy = await this.handleApiError((api) => api.getDeploy({ deployId: deploymentId }));
250
352
  const connection = await this.getWsConnection(deploymentId);
@@ -1,4 +1,4 @@
1
- import type { Credentials, Deployment, Details, Log, Options, Project, TriggerResult } from '@directus/types';
1
+ import type { Credentials, Deployment, DeploymentWebhookEvent, Details, Log, Options, Project, Status, TriggerResult, WebhookRegistrationResult } from '@directus/types';
2
2
  import { DeploymentDriver } from '../deployment.js';
3
3
  export interface VercelCredentials extends Credentials {
4
4
  access_token: string;
@@ -25,8 +25,11 @@ export declare class VercelDriver extends DeploymentDriver<VercelCredentials, Ve
25
25
  preview?: boolean;
26
26
  clearCache?: boolean;
27
27
  }): Promise<TriggerResult>;
28
- cancelDeployment(deploymentId: string): Promise<void>;
28
+ cancelDeployment(deploymentId: string): Promise<Status>;
29
29
  getDeploymentLogs(deploymentId: string, options?: {
30
30
  since?: Date;
31
31
  }): Promise<Log[]>;
32
+ registerWebhook(webhookUrl: string, projectIds: string[]): Promise<WebhookRegistrationResult>;
33
+ unregisterWebhook(webhookIds: string[]): Promise<void>;
34
+ verifyAndParseWebhook(rawBody: Buffer, headers: Record<string, string | string[] | undefined>, webhookSecret: string): DeploymentWebhookEvent | null;
32
35
  }
@@ -1,6 +1,13 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
1
2
  import { HitRateLimitError, InvalidCredentialsError, ServiceUnavailableError } from '@directus/errors';
2
3
  import pLimit from 'p-limit';
3
4
  import { DeploymentDriver } from '../deployment.js';
5
+ const VERCEL_WEBHOOK_EVENTS = [
6
+ 'deployment.created',
7
+ 'deployment.succeeded',
8
+ 'deployment.error',
9
+ 'deployment.canceled',
10
+ ];
4
11
  export class VercelDriver extends DeploymentDriver {
5
12
  static API_URL = 'https://api.vercel.com';
6
13
  requestLimit = pLimit(5);
@@ -83,8 +90,14 @@ export class VercelDriver extends DeploymentDriver {
83
90
  return result;
84
91
  }
85
92
  async listProjects() {
86
- const response = await this.request('/v9/projects');
87
- return response.projects.map((project) => this.mapProjectBase(project));
93
+ const allProjects = [];
94
+ let until;
95
+ do {
96
+ const response = await this.request('/v9/projects', { params: { limit: '100', ...(until ? { until } : {}) } });
97
+ allProjects.push(...response.projects.map((project) => this.mapProjectBase(project)));
98
+ until = response.pagination?.next ? String(response.pagination.next) : undefined;
99
+ } while (until);
100
+ return allProjects;
88
101
  }
89
102
  async getProject(projectId) {
90
103
  const project = await this.request(`/v9/projects/${projectId}`);
@@ -171,6 +184,7 @@ export class VercelDriver extends DeploymentDriver {
171
184
  const triggerResult = {
172
185
  deployment_id: response.id,
173
186
  status: this.mapStatus(response.status),
187
+ created_at: new Date(response.createdAt),
174
188
  };
175
189
  if (response.url) {
176
190
  triggerResult.url = `https://${response.url}`;
@@ -178,9 +192,22 @@ export class VercelDriver extends DeploymentDriver {
178
192
  return triggerResult;
179
193
  }
180
194
  async cancelDeployment(deploymentId) {
181
- await this.request(`/v12/deployments/${encodeURIComponent(deploymentId)}/cancel`, {
182
- method: 'PATCH',
183
- });
195
+ try {
196
+ await this.request(`/v12/deployments/${encodeURIComponent(deploymentId)}/cancel`, {
197
+ method: 'PATCH',
198
+ });
199
+ return 'canceled';
200
+ }
201
+ catch {
202
+ const details = await this.getDeployment(deploymentId);
203
+ if (details.status !== 'building') {
204
+ return details.status;
205
+ }
206
+ throw new ServiceUnavailableError({
207
+ service: 'vercel',
208
+ reason: `Could not cancel the deployment: ${deploymentId}`,
209
+ });
210
+ }
184
211
  }
185
212
  async getDeploymentLogs(deploymentId, options) {
186
213
  let url = `/v3/deployments/${encodeURIComponent(deploymentId)}/events`;
@@ -205,4 +232,56 @@ export class VercelDriver extends DeploymentDriver {
205
232
  message: event.text || event.payload?.text || '',
206
233
  }));
207
234
  }
235
+ async registerWebhook(webhookUrl, projectIds) {
236
+ const response = await this.request('/v1/webhooks', {
237
+ method: 'POST',
238
+ body: JSON.stringify({
239
+ url: webhookUrl,
240
+ events: VERCEL_WEBHOOK_EVENTS,
241
+ projectIds,
242
+ }),
243
+ });
244
+ return {
245
+ webhook_ids: [response.id],
246
+ webhook_secret: response.secret,
247
+ };
248
+ }
249
+ async unregisterWebhook(webhookIds) {
250
+ for (const id of webhookIds) {
251
+ await this.request(`/v1/webhooks/${encodeURIComponent(id)}`, {
252
+ method: 'DELETE',
253
+ });
254
+ }
255
+ }
256
+ verifyAndParseWebhook(rawBody, headers, webhookSecret) {
257
+ const signature = headers['x-vercel-signature'];
258
+ if (!signature || typeof signature !== 'string') {
259
+ return null;
260
+ }
261
+ const expected = createHmac('sha1', webhookSecret).update(rawBody).digest('hex');
262
+ if (signature.length !== expected.length || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
263
+ return null;
264
+ }
265
+ const body = JSON.parse(rawBody.toString('utf-8'));
266
+ if (!VERCEL_WEBHOOK_EVENTS.includes(body.type)) {
267
+ return null;
268
+ }
269
+ const eventTypeToStatus = {
270
+ 'deployment.created': 'building',
271
+ 'deployment.succeeded': 'ready',
272
+ 'deployment.error': 'error',
273
+ 'deployment.canceled': 'canceled',
274
+ };
275
+ return {
276
+ type: body.type,
277
+ provider: 'vercel',
278
+ project_external_id: body.payload.project.id,
279
+ deployment_external_id: body.payload.deployment.id,
280
+ status: eventTypeToStatus[body.type] ?? 'building',
281
+ ...(body.payload.deployment.url ? { url: `https://${body.payload.deployment.url}` } : {}),
282
+ ...(body.payload.target ? { target: body.payload.target } : {}),
283
+ timestamp: new Date(body.createdAt),
284
+ raw: body,
285
+ };
286
+ }
208
287
  }
@@ -22,3 +22,8 @@ export declare function isValidProviderType(provider: string): provider is Provi
22
22
  * Get list of supported provider types
23
23
  */
24
24
  export declare function getSupportedProviderTypes(): ProviderType[];
25
+ /**
26
+ * Sync webhooks for existing deployment configs that don't have one yet.
27
+ * Called at startup to handle configs created before webhook support was added.
28
+ */
29
+ export declare function ensureDeploymentWebhooks(): Promise<void>;
@@ -1,4 +1,8 @@
1
+ import getDatabase from './database/index.js';
1
2
  import { NetlifyDriver, VercelDriver } from './deployment/drivers/index.js';
3
+ import { useLogger } from './logger/index.js';
4
+ import { DeploymentService } from './services/deployment.js';
5
+ import { getSchema } from './utils/get-schema.js';
2
6
  /**
3
7
  * Registry of deployment driver constructors
4
8
  */
@@ -38,3 +42,33 @@ export function isValidProviderType(provider) {
38
42
  export function getSupportedProviderTypes() {
39
43
  return Array.from(drivers.keys());
40
44
  }
45
+ /**
46
+ * Sync webhooks for existing deployment configs that don't have one yet.
47
+ * Called at startup to handle configs created before webhook support was added.
48
+ */
49
+ export async function ensureDeploymentWebhooks() {
50
+ const logger = useLogger();
51
+ const knex = getDatabase();
52
+ const schema = await getSchema();
53
+ const service = new DeploymentService({
54
+ knex,
55
+ schema,
56
+ accountability: null,
57
+ });
58
+ const configs = await service.readByQuery({
59
+ limit: -1,
60
+ });
61
+ if (!configs || configs.length === 0) {
62
+ logger.debug('[webhook] No deployment configs found');
63
+ return;
64
+ }
65
+ logger.debug(`[webhook] Syncing webhooks for ${configs.length} config(s)...`);
66
+ for (const config of configs) {
67
+ try {
68
+ await service.syncWebhook(config.provider);
69
+ }
70
+ catch (err) {
71
+ logger.error(`[webhook] Failed to sync webhook for ${config.provider}: ${err}`);
72
+ }
73
+ }
74
+ }
@@ -1,3 +1,4 @@
1
+ import { parseJsonFunction } from '../../database/helpers/fn/json/parse-function.js';
1
2
  import { parseFilterKey } from '../../utils/parse-filter-key.js';
2
3
  /**
3
4
  * Derive the unaliased field key from the given AST node.
@@ -10,8 +11,15 @@ export function getUnaliasedFieldKey(node) {
10
11
  case 'm2o':
11
12
  return node.relation.field;
12
13
  case 'field':
13
- case 'functionField':
14
14
  // The field name might still include a function, so process that here as well
15
15
  return parseFilterKey(node.name).fieldName;
16
+ case 'functionField':
17
+ if (node.name.startsWith('json')) {
18
+ return parseJsonFunction(node.name).field;
19
+ }
20
+ else {
21
+ // The field name might still include a function, so process that here as well
22
+ return parseFilterKey(node.name).fieldName;
23
+ }
16
24
  }
17
25
  }
@@ -1,7 +1,5 @@
1
- import os from 'node:os';
2
1
  import { useEnv } from '@directus/env';
3
- import { ipInNetworks } from '@directus/utils/node';
4
- import { matches } from 'ip-matching';
2
+ import { IpBlocklist } from '@directus/utils/node';
5
3
  import { useLogger } from '../logger/index.js';
6
4
  export function isDeniedIp(ip) {
7
5
  const env = useEnv();
@@ -9,31 +7,34 @@ export function isDeniedIp(ip) {
9
7
  const ipDenyList = env['IMPORT_IP_DENY_LIST'];
10
8
  if (ipDenyList.length === 0)
11
9
  return false;
10
+ const blockList = new IpBlocklist();
11
+ let blockNetworkInterfaces = false;
12
12
  try {
13
- const denied = ipInNetworks(ip, ipDenyList);
14
- if (denied)
15
- return true;
13
+ for (const blockNetworkRaw of ipDenyList) {
14
+ const blockNetwork = blockNetworkRaw.trim();
15
+ if (blockNetwork === '0.0.0.0') {
16
+ blockNetworkInterfaces = true;
17
+ continue;
18
+ }
19
+ if (blockNetwork.includes('-')) {
20
+ blockList.parseRange(blockNetwork);
21
+ continue;
22
+ }
23
+ if (blockNetwork.includes('/')) {
24
+ blockList.parseSubnet(blockNetwork);
25
+ continue;
26
+ }
27
+ blockList.parseAddress(blockNetwork);
28
+ }
29
+ if (blockNetworkInterfaces) {
30
+ blockList.addLocalNetworkInterfaces();
31
+ }
16
32
  }
17
33
  catch (error) {
34
+ // error adding blocked ranges to the blocklist
18
35
  logger.warn(`Cannot verify IP address due to invalid "IMPORT_IP_DENY_LIST" config`);
19
36
  logger.warn(error);
20
37
  return true;
21
38
  }
22
- if (ipDenyList.includes('0.0.0.0')) {
23
- const networkInterfaces = os.networkInterfaces();
24
- for (const networkInfo of Object.values(networkInterfaces)) {
25
- if (!networkInfo)
26
- continue;
27
- for (const info of networkInfo) {
28
- if (info.internal && info.cidr) {
29
- if (matches(ip, info.cidr))
30
- return true;
31
- }
32
- else if (info.address === ip) {
33
- return true;
34
- }
35
- }
36
- }
37
- }
38
- return false;
39
+ return blockList.checkAddress(ip);
39
40
  }